--[[
 exile_songs;
 Plays ambient music for Exile (or any game technically, though should be for Exile!!!)
 tracks specifically crafted for the environment of Exile
 
 even will play any music you add to /sounds/ ! So feel free to customize your experience :3
 
    Copyright (C) 2024 TPH (see readme.txt for more info)

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.
--]]

local is_v4 = minetest.get_modpath("tgcr") and true

local music_choices = {}

-- not fully sure how this precisely works, see my reference:
-- https://forums.x-plane.org/index.php?/forums/topic/183135-get-wav-file-duration-using-flywithlua/
-- depends on byte values obtained with string.byte() for data
local function BinToBit(data)
  local length = #data -- 4 or 8, 32 if 4, 64 if 8
  local multiplier = 256
  local bit = data[1] -- first byte value not being multiplied
  for i=2, length do
    if data[i] == 0 then break end -- no point to more calculation if it's a 0 anyways!
    bit = bit + (data[i]*multiplier) -- we add to the bit number by the byte value times the multiplier
    multiplier = multiplier^2 -- exponent the multiplier by 2
  end
  return bit
end

-- reference: https://stackoverflow.com/questions/20794204/how-to-determine-length-of-ogg-file
-- gets the duration of an audio file (ogg vorbis only, don't try this with anyone else lol)
-- round_decimal for how many decimals you want kept (integer)
local function get_song_duration(path, round_decimal)
  -- permit file being path if it's a file object
  local file = type(path) == "userdata" and path or io.open(path, "rb")
  local size = file:seek("end") -- go to end of file
  file:seek("end",-size) -- bring reader back to beginning
  local file_string = file:read(size) -- now utilize the size of the file to get the ENTIRE thing
  file:close() -- don't need this anymore, close it!
  -- as mentioned in the stackoverflow, we have to find the last OggS -- which we do by reversing the string
  -- and finding it, reversed! and we get its index, we only need the first return
  local ind = file_string:reverse():find("SggO")
  ind = size - ind -- we've gotta inverse it to get the position we want
  ind = ind + 3 -- so we skip a byte to get past the S of OggS, then 2 more to get past the 2 unused bytes
  -- now we create a table of bytes to be used in BinToBit
  local dur = {}
  -- duration will be 64bit (8 bytes)
  for i=1, 8 do
    i = ind + i -- add ind to i for sub
    dur[#dur + 1] = file_string:sub(i,i):byte() -- convert to byte value
  end
  --core.log(table.concat(dur," ")) -- see the 8 bytes in their glory!
  dur = BinToBit(dur)
  -- now to get rate -- we get the value after the first vorbis
  ind = file_string:find("vorbis")
  ind = ind + 10 -- we skip 10 bytes, not entirely sure why
  local rate = {}
  -- rate will be 32bit (4 bytes)
  for i=1, 4 do
    i = ind + i -- add ind to i for sub
    rate[#rate + 1] = file_string:sub(i,i):byte()
  end
  --core.log(table.concat(rate," ")) -- see the 4 bytes in their glory!
  rate = BinToBit(rate)
  -- now to divide it all together!
  local duration = dur/rate
  -- round the song's duration a bit so it isn't so chaotic
  round_decimal = round_decimal or 2
  duration = math.floor((duration * (10^round_decimal)) + 0.5)/(10^round_decimal)
  return duration
end

-- load up provided songs into music_choices
local sounds_path = core.get_modpath("exile_songs").."/sounds/"
local songs = core.get_dir_list(sounds_path)
for _,name in pairs(songs) do
  -- verify if supported file (.ogg)
  if name:sub(-4) == ".ogg" then
    -- add sounds_path as we just have the name and file extension
    local songpath = sounds_path..name
    -- add to music choices
    music_choices[#music_choices + 1] = {
      name = name:sub(1,-5), -- Luanti like no .ogg
      -- get the length!
      rawlength = get_song_duration(songpath)
    }
    -- just shows the song's file name with its found duration
    --local songdata = music_choices[#music_choices]
    --core.log(songdata.name.." : "..songdata.length)
  end
end
--[[
  Feel free to put in your own songs (ogg vorbis) in the /sounds/ file! This code will properly get the duration and play it accordingly!
  I might make this into an API at some point because of how useful this can be in my opinion
--]]

local function concat_text(cmd,...)
    cmd = type(cmd) == "string" and cmd or ""
    local text = {...}
    return table.concat(text,cmd)
end

-- utilizes os.time() to properly play songs even if game is paused
-- used to determine when to play a new song
-- core.after and globalstep do not account for when the game is paused
local base_time = os.time() -- utilize os.time() at load time to create a reference
local function get_time(since)
  -- time since base_time
  local return_time = os.time() - base_time
  -- if provided, use the provided get_time() value aka "since"
  -- to create a references of seconds since, well, the since
  if since then
    return_time = return_time - since
  end
  return return_time
end

-- function for choosing a song
-- last_played is used to avoid playing the same song after it had played
local function pick_song(last_played)
  local song = music_choices[math.random(1, #music_choices)]
  -- we aint about to infinite loop
  if last_played and #music_choices > 1 then
    -- derive name from table
    last_played = type(last_played) == "table" and last_played.name or last_played
    -- we basically just don't want to play the same song again lol
    while song.name == last_played  do
      song = music_choices[math.random(1, #music_choices)]
    end
  end
  return song
end

-- basics for creating a data table for music
local function format_playable_music_table(data)
  data.gain = data.gain or 0.15
  data.pitch = data.pitch or 1
  -- get rawlength for calculation
  data.length = data.rawlength or data.length
  -- divide length by pitch to get proper length
  -- lower pitches would make a longer length, higher ones a shorter length
  data.length = data.length/data.pitch
  data.started = get_time()
  return data
end

-- data, plrname, last_played
-- data is a sound spec, plrname is a player's name (used for to_player), and last_played is a string or table with name string
local function play_music(data, plrname, stats)
  data = type(data) == "table" and data or pick_song()
  if not (data.name or data.length) then return end
  data = table.copy(data)
  if stats then
    data.gain = stats.song_gain
    data.pitch = stats.song_pitch
  end
  format_playable_music_table(data)
  data.to_player = plrname
  data.id = core.sound_play(data.name, data)
  return data
end

-- for determining if there's an exile_theme to check for and avoid playing music until over
local lore_path = core.get_modpath("lore") -- check for lore mod
local exile_theme
if lore_path then
  -- set up sound name, path
  exile_theme = "exile_theme"
  exile_theme = {path=lore_path.."/music/"..exile_theme..".ogg", name=exile_theme}
end

-- sneaky sneaky you!
local function unique_message()
  if lore_path then return end -- we're playing Exile, no need
  local username = "<TPH> "
  if core.is_singleplayer() then
    local messages = {
      "Huh... You're using my songs mod... But not for Exile..?",
      "I mean, yeah sure, it works without Exile, but it's surprising to see this somewhere else!",
      "Exile is free and open source, with more features to come :D but I'm glad you liked this mod enough to...",
      "Use it in another game!",
      "Hope these messages aren't too annoying - thought it'd be funny for a somewhat self-aware mod pfft x3",
      "Have a great day and especially have fun! :3",
    }
    local len = 0
    for _,mes in pairs(messages) do
      len = len + #mes/12
      minetest.after(len, function()
        core.chat_send_all(username..mes)
      end)
    end
  end
end


local listening = {}

-- check table
-- count is based off of dtime
-- verify checks through listening table to check whether or not someone needs a new song to play
local check = {count = 0, verify = -1}
-- only start music globalstep after all mods have loaded
core.register_on_mods_loaded(function()
  minetest.after(2.5, unique_message)
  core.register_globalstep(function(dtime)
    check.count = check.count + dtime
    -- music function
    if check.count > check.verify then
      -- check every 0.5 seconds
      check.verify = check.count + 0.5
      for name,stats in pairs(listening) do
        -- do not run if player doesn't like music
        if not stats.mute then
          -- mechanics with when a song is playing
          if stats.song then
            local song = stats.song -- for easier handling
            -- if this song is over, stop and remove the song data from stats
            -- if skipat, check that first (should be a number produced by exile_song skip command)
            if get_time(song.started) > (song.skipat or song.length) then
              -- will crash if there's no ID, so let's avoid that if there isn't one
              if song.id then
                  core.sound_stop(song.id)
              end
              -- set last_played to name
              stats.last_played = table.copy(stats.song)
              stats.song = nil
            end
          end
          -- no song is playing, let's play one!
          if not stats.song then
            -- check last_played so that play_music doesn't play the same old song
            local newsong = pick_song(stats.last_played)
            stats.song = play_music(newsong, name, stats)
            -- add 2 second delay
            stats.song.length = stats.song.length + 2
          end
        end
      end
    end
  end)
end)

-- ONLY use on new player event
local function get_exile_theme_data()
  if exile_theme then
    -- no duration was got, crack open the file!
    if not exile_theme.duration then
      exile_theme.file = io.open(exile_theme.path, "rb")
      -- no file, no worries, remove exile_theme reference
      if not exile_theme.file then
        exile_theme = nil
        return
      end
      -- file gets closed in get_song_duration
      exile_theme.duration = get_song_duration(exile_theme.file)
      -- remove reference
      exile_theme.file = nil
    end
    -- there obviously is a theme, return true!
    return true
  end
end

local newplayers = {}

core.register_on_newplayer(function(plr)
  newplayers[plr:get_player_name()] = plr
end)

-- clear out when player leaves
core.register_on_leaveplayer(function(plr)
  listening[plr:get_player_name()] = nil
end)

local function song_mute(stats)
  if stats.mute then return end -- return if muted already
  stats.mute = true -- set true
  -- stop sound if found id
  local sid = stats.song and stats.song.id
  if sid then
    core.sound_stop(sid)
  end
  -- add song to last_played, remove song data
  stats.last_played = stats.song and table.copy(stats.song)
  stats.song = nil
end

-- functions for commands
-- check if song has id, name, and length
local function adequate_data(song)
  -- if we have song, sid, sname, and slen
  if type(song) == "table" and song.id and song.name and song.length then return true end
  -- otherwise FALSE
  return false
end
-- mute error
-- custom boolean partof for determining whether or not the "cmd" should be a message in the error or the command's name
local function muterr(cmd, partof)
  cmd = partof and cmd or concat_text(nil,'"',cmd,'"')
  return false, concat_text(nil,'Cannot ',cmd,' song, you have muted songs, use /exile_song unmute to play them')
end
-- refresh function
local function song_refresh(name, stats)
  local song = stats.song
  -- you muted!!!
  if stats.mute then return muterr("refresh") end
  -- inadequate data
  if not adequate_data(song) then
    return false, "Song cannot be refreshed (no data or id or name or length)"
  end
  if song.unskippable then
    return false, "Song cannot be refreshed (unskippable)"
  end
  -- too close, try again another day
  if get_time(song.started) > song.length - 1.5 then
    return false, "Song cannot be refreshed as it is ending"
  end
  -- restart
  core.sound_stop(song.id)
  -- utilize own song data to replay
  stats.song = play_music(song, name, stats) -- just play it again lol
  return true, concat_text(nil,'"',song.name,'" successfully refreshed')
end

-- commands
core.register_chatcommand("exile_song", {
  func = function(name, param)
    param = param:lower() -- lowercase
    local params = param:split(" ") -- split into individual commands
    -- no commands specified
    if #params == 0 then
      return false, "No command specified, refer to /help to see commands"
    end
    local stats = listening[name]
    local cmd = params[1]
    -- define for easier use
    local song = stats.song
    local sid = song and song.id
    local slen = song and song.length
    local sname = song and song.name
    -- skip!
    if cmd == "skip" then
      -- you muted!!!
      if stats.mute then return muterr(cmd) end
      -- inadequate data
      if not adequate_data(song) then
        return false, "Song cannot be skipped (no song data or id or name or length)"
      end
      -- we're already skipping this, silly!
      if song.skipat then
        return false, "Song is already being skipped, be patient!"
      end
      -- used for other measures
      if song.unskippable then
        return false, "Song cannot be skipped (unskippable is set)"
      end
      -- get_time will be seconds since song started, leftover would be length minus it
      local since = get_time(song.started)
      -- this song is about to end anyways!
      if since + 4 > slen then
        return false, "Song is ending, not skippable"
      end
      -- fade song out
      -- gain should decrease by a third of gain per second
      core.sound_fade(sid, song.gain/3, 0)
      -- modify length so that it cancels the song
      -- add 3 second delay
      song.skipat = since + 3
      return true, '"'..sname..'" successfully skipped'
    -- refresh
    elseif cmd == "refresh" then
      return song_refresh(name, stats)
    -- play previous
    elseif cmd == "prev" then
      -- you muted!!!
      if stats.mute then return muterr("go to previous", true) end
      -- this command does not depend on a proper song data value, so we won't check for it
      if (not stats.last_played) or stats.last_played == sname then
        return false, "No last_played in information"
      end
      -- likely impossible to get but may as well check for it
      if song and song.unskippable then
        return false, "Cannot play previous due to unskippable song"
      end
      local old_song = stats.last_played -- create quickie table before last_played is replaced
      -- improper last_played stats
      if type(old_song) ~= "table" or not (old_song.length or old_song.name) then
        return false, "Information of previous song cannot be properly yielded, unable to play previous"
      end
      -- stop actual sound if ID is available
      if sid then
        core.sound_stop(sid)
      end
      stats.last_played = stats.song -- set current song to last_played
      stats.song = play_music(old_song, name, stats) -- play previous song
      stats.song.skipat = nil -- remove any potential skip shenanigans
      return true, 'playing previous "'..stats.song.name..'" from before current "'..
        (stats.last_played and stats.last_played.name or 'unknown')..'"'
    -- mute
    elseif cmd == "mute" then
      if stats.mute then
        return false, "Songs already muted"
      end
      -- tell code that we're muted
      song_mute(stats)
      return true, "Successfully muted songs"
    elseif cmd == "unmute" then
      if not stats.mute then
        return false, "You haven't muted songs"
      end
      stats.mute = nil
      return true, "Successfully unmuted songs"
    -- get details of currently playing song
    elseif cmd == "detail" or cmd == "details" then
      if stats.mute then return muterr("get details of",true) end
      if not adequate_data(song) then
        return false, "Details of song cannot be grabbed (no data or id or name or length)"
      end
      local song = stats.song
      local statprint = {'{ name: "',song.name,'"'}
      -- figure out length of song
      -- e.g. 1min40sec
      local length = {
          -- get min from song length divided by 60 (song length is in seconds)
          -- if song length is over 59 seconds
          min = song.length > 59 and math.floor(song.length/60)
      }
      -- get leftover sec from minutes if got minutes
      length.sec = length.min and song.length-(length.min*60) or song.length
      length.sec = math.floor((length.sec + 0.05) * 10)/10 -- round first decimal
      -- figure out hour if we're somehow over 59 minutes long!
      length.hour = length.min and length.min > 59 and math.floor(length.min/60)
      -- figure leftover minutes from hours if we calculated hours
      length.min = length.hour and length.min-(length.hour*60) or length.min
      -- concatenate all the times together
      -- or "" argument needed because of table'ing the ... functionality accepts nil parameters and causes error with concat
      length = concat_text(nil,(length.hour and concat_text(nil,length.hour,"h") or ""),
        (length.min and concat_text(nil,length.min,"min") or ""),concat_text(nil,length.sec,"sec"))
      -- print out stat
      statprint[#statprint + 1] = concat_text(nil," length: ",length)
      -- get pitch and gain
      statprint[#statprint + 1] = concat_text(nil," pitch: ",song.pitch)
      statprint[#statprint + 1] = concat_text(nil," gain: ",song.gain)
      -- get progress percent if started provided
      if song.started then
          -- rounds down decimal of percentage
          statprint[#statprint + 1] = concat_text(nil," progress: ",
            math.floor((get_time(song.started)/song.length)*1000)/10,"%")
      end
      -- finalization
      statprint[#statprint + 1] = " }"
      return true, table.concat(statprint)
    -- volume/gain command
    elseif cmd == "gain" or cmd == "volume" or cmd == "vol" then
      if stats.mute then return muterr("change volume of",true) end
      if not adequate_data(song) then
        return false, "Song's volume cannot be changed (no data or id or name or length)"
      end
      -- check if there's a 2nd paramter
      if not params[2] then
        return false,"No volume provided to set"
      end
      -- get gain from 2nd paramter
      local gain = params[2] and params[2]:lower()
      gain = (gain == "nm" or gain == "normal" or gain == "reset") and 0.15 or tonumber(gain)
      if type(gain) ~= "number" then
        return false,"Did not get appropriate number to change song's volume"
      end
      -- gain check and setting
      if gain == stats.song_gain or not stats.song_gain and gain == 0.15 then
        return false,concat_text(nil,"Volume is already set to ",gain)
      elseif gain < 0.01 then
        return false,"Specified volume is too low! (above or equal to 0.01, below or equal to 3)"
      elseif gain > 3 then
        return false,"Specified volume is too high! (above or equal to 0.01, below or equal to 3)"
      end
      stats.song_gain = gain ~= 0.15 and gain or nil -- set to stats (if 0.15, remove)
      -- check if we should refresh
      local refsh = params[3] and params[3]:lower()
      refsh = refsh == "yes" or refsh == "true" and true
      if refsh then
        local can,errmsg = song_refresh(name, stats)
        if not can then
          return false,concat_text(nil,"Set voule to ",gain,' but could not refresh "',sname,'", see below',
            "\n",errmsg)
        end
        return true,concat_text(nil,"Successfully set your volume to ",gain,' and refreshed "',sname,'"')
      end
      return true,concat_text(nil,"Successfully set your volume to ",gain,", refresh or skip current song to apply effects")
    -- pitch/speed command
    elseif cmd == "pitch" or cmd == "speed" then
      if stats.mute then return muterr("change pitch of",true) end
      if not adequate_data(song) then
        return false, "Song's pitch cannot be changed (no data or id or name or length)"
      end
      -- check if there's a 2nd paramter
      if not params[2] then
        return false,"No pitch provided to set"
      end
      -- get gain from 2nd paramter
      local pitch = params[2] and params[2]:lower()
      pitch = (pitch == "nm" or pitch == "normal" or pitch == "reset") and 1 or tonumber(pitch)
      if type(pitch) ~= "number" then
        return false,"Did not get appropriate number to change song's pitch"
      end
      -- pitch check and setting
      if pitch == stats.song_pitch or not stats.song_pitch and pitch == 1 then
        return false,concat_text(nil,"Pitch is already set to ",pitch)
      elseif pitch < 0.05 then
        return false,"Specified pitch is too low! (above or equal to 0.05, below or equal to 10)"
      elseif pitch > 10 then
        return false,"Specified pitch is too high! (above or equal to 0.05, below or equal to 10)"
      end
      stats.song_pitch = pitch ~= 1 and pitch or nil -- set to stats (if 1, remove)
      -- check if we should refresh
      local refsh = params[3] and params[3]:lower()
      refsh = refsh == "yes" or refsh == "true" and true
      if refsh then
        local can,errmsg = song_refresh(name, stats)
        if not can then
          return false,concat_text(nil,"Set pitch to ",pitch,' but could not refresh "',sname,'", see below',
            "\n",errmsg)
        end
        return true,concat_text(nil,"Successfully set your pitch to ",pitch,' and refreshed "',sname,'"')
      end
      return true,concat_text(nil,"Successfully set your pitch to ",pitch,", refresh or skip current song to apply effects")
    -- invalid command error
    else
      return false, concat_text(nil,'"', cmd, '" is not a valid command')
    end
  end,
  description = concat_text("\n",
    "Command handler for Exile Ambient Music",
      "/exile_song skip -- skips current playing song",
      "/exile_song refresh -- starts song anew",
      "/exile_song prev -- plays previously played song",
      "/exile_song mute -- mutes any songs playing for you",
      "/exile_song unmute -- plays songs again for you after muting them",
      "/exile song detail -- lists the name, length, pitch, and gain of a currently playing song",
      concat_text(nil,"/exile song gain/vol/volume <gain> [refresh] -- sets song volume and will refresh if",
        " 'refresh' is 'yes' or 'true'. <gain> can be 'nm', 'normal', or 'reset' to reset back to 0.15"),
      concat_text(nil,"/exile song pitch/speed <pitch> [refresh] -- sets song pitch and will refresh if ",
        "'refresh' is 'yes' or 'true'. <pitch> can be 'nm', 'normal', or 'reset' to reset back to 1")
  )
})

local function player_setts(player, fields, pname)
  -- sets to true if true, otherwise sets to nil if anything but false and if false, sets as false
  local nomusic = fields.nomusic == "true" and true or fields.nomusic ~= "false" and nil
  if type(nomusic) ~= "boolean" then return end -- no modifications noted, return
  local stats = listening[pname]
  if not stats then return end -- odd, how did this happen? no stats so return
  -- mute
  if nomusic then
    song_mute(stats)
    return
  end
  -- unmute
  stats.mute = nil
end

local function set_newplayer_stats(player, pname)
  -- only get data if newplayer
  local is_theme = get_exile_theme_data()
  -- no theme, shouldn't do anything
  if not is_theme then return end
  -- beginning theme was found, get the data we need
  -- get information on duration (don't need name)
  local song = {
    length = exile_theme.duration, -- check lore_path value
    name = "exile_theme"
  }
  format_playable_music_table(song) -- format accordingly
  song.length = song.length + 10 -- 8 for minetest.after in lore/login (add a bit for potential lag)
  -- takes longer to spawn in v4, idk why, so add 45 instead of 20
  --song.length = is_v4 and song.length + 45 or 20 -- 20 for whatever was being read
  -- create listening table with exile_theme song
  listening[pname] = {song = song}
end

-- spawning_wait_func is a v4 exclusive
-- checks the spawning meta for newplayers, creates stats otherwise
local spawning_wait_func
spawning_wait_func = function(player, pname, meta)
  meta = meta or player:get_meta()
  -- it's a position string
  local spawning = meta:get_string("spawning")
  -- was cleared meaning we just spawned in
  if spawning == "" then
    set_newplayer_stats(player, pname)
    return
  end
  -- check every 0.5 seconds
  core.after(0.5, spawning_wait_func, player, pname, meta)
end

-- add stats table when player joins
core.register_on_joinplayer(function(plr)
  local pname = plr:get_player_name()
  -- do NOT conflict with newplayer
  if newplayers[pname] then return end
  local stats = {}
  -- get meta to check for disable_music
  local pmeta = plr:get_meta()
  if pmeta then
    -- if meta, check disable_music and set true if enabled, otherwise nil
    stats.mute = pmeta:get_string("disable_music") == "true" and true or nil
    -- player quit before properly spawning in v4 and didn't mute music
    if not stats.mute and is_v4 and pmeta:get_string("spawning") ~= "" then
      spawning_wait_func(plr, pname, pmeta)
      return
    end
  end
  -- add stats
  listening[pname] = stats
end)

-- mute songs if disable music is enabled
core.register_on_player_receive_fields(function(player, formname, fields)
  -- return if we're not doing any of these
  if not (formname == "player_settings" or formname == "lore:login") then
    return
  end
  local pname = player:get_player_name()
  -- player settings

  if formname == "player_settings" then
    player_setts(player, fields, pname)
  -- new player has now exited the login page, give stats
  elseif formname == "lore:login" and newplayers[pname] then
    newplayers[pname] = nil
    -- v4 takes longer to load and has a spawning string in meta
    if is_v4 then
      spawning_wait_func(player, pname)
    -- v3
    else
      set_newplayer_stats(player, pname)
    end
  end
end)

-- #TODO:
-- have certain songs be biome specific
-- have some system for where it's more customizable - having custom pitches and volumes be specified
