--------------------------------------------------------------------------------------------
-- Mod reader code created by Rainault
-- Released to the public domain
-- Last update: 2024-02-21
--------------------------------------------------------------------------------------------

function easeLerp(t, from, to)
   return (1 - t) * from + t * to
end

function easePtLerp(t, from, to)
    return {
        easeLerp(t, from[1], to[1]),
        easeLerp(t, from[2], to[2]),
    }
end

function easeBlend(n, t, fromFunc, toFunc)
   return easeLerp(n, fromFunc(t), toFunc(t))
end

function easeCrossFade(t, fromFunc, toFunc)
   return easeBlend(t, t, fromFunc, toFunc)
end

function easeLinear(t) return t end
function easeSmoothStart2(t) return t * t end
function easeSmoothStart3(t) return t * t * t end
function easeSmoothStart4(t) return t * t * t * t end
function easeSmoothEnd2(t) return 1 - easeSmoothStart2(1 - t) end
function easeSmoothEnd3(t) return 1 - easeSmoothStart3(1 - t) end
function easeSmoothEnd4(t) return 1 - easeSmoothStart4(1 - t) end
function easeSmoothStep2(t) return easeCrossFade(t, easeSmoothStart2, easeSmoothEnd2) end
function easeSmoothStep3(t) return easeCrossFade(t, easeSmoothStart3, easeSmoothEnd3) end
function easeSmoothStep4(t) return easeCrossFade(t, easeSmoothStart4, easeSmoothEnd4) end
function easeRebound2(t) return 4 * t * (1 - t) end
function easeRebound4(t) local t2 = easeRebound2(t); return 1 - (1 - t2) * (1 - t2) end
function easeSpringEnd(t) return t >= 1 and 1 or math.pow(2, (-10 * t)) * math.sin((t - 0.3 / 4) * (2 * math.pi) / 0.3) + 1 end

local function normalizePlayer(player)
   local pidx, pid = nil, nil

   if player == 1 or player == PLAYER_1 then
      pidx = 1
      pid = PLAYER_1
   elseif player == 2 or player == PLAYER_2 then
      pidx = 2
      pid = PLAYER_2
   end

   return pidx, pid
end

function calculatePlayerMetrics(pidx)
   pidx, _ = normalizePlayer(pidx)

   -- Ripped from a TaroNuke script, I think
   local musicRate = 1
   local soptions = GAMESTATE:GetSongOptions('ModsLevel_Song')
   local xmu = string.find(soptions,'xMusic')
   if xmu then
      local begin = xmu-1
      while begin > 0 and string.match(string.sub(soptions,begin,begin),'[.%d]') do
         begin = begin - 1
      end
      local rateString = string.sub(soptions, begin + 1, xmu - 1)
      musicRate = tonumber(rateString)
   end

   local options = GAMESTATE:GetPlayerState(pidx-1):GetPlayerOptions('ModsLevel_Song')

   local metrics = {}
   metrics.difficulty = GAMESTATE:GetCurrentSteps(pidx-1):GetDifficulty()
   metrics.scrollDirection = options:Reverse() == 0 and 1 or -1
   metrics.mirror = options:Mirror()
   metrics.arrowScale = 1 - options:Mini() / 2

   local cMod = options:MMod() or options:CMod()
   if cMod then
      metrics.speed = cMod / GAMESTATE:GetCurrentSong():GetDisplayBpms()[2]
   else
      metrics.speed = options:XMod()
   end

   metrics.musicRate = musicRate

   metrics.getColumnEndpoints = function(self, columnIdx)
      local columnOffset = GAMESTATE:GetCurrentStyle():GetName() == 'double' and 4.5 or 2.5
      local columnStart = {
         64 * (columnIdx - columnOffset) * self.scrollDirection,
         -135 / self.arrowScale * self.scrollDirection,
      }
      local columnEnd = {
         columnStart[1],
         columnStart[2] + 1024 * self.scrollDirection,
      }
      return columnStart, columnEnd
   end

   return metrics
end

getVar = nil
reportError = nil
reportErrorOnce = nil

function LibActor(args)
   local inputTable = args.inputTable
   local useRealTime = args.useRealTime
   local usePlayFieldMods = args.usePlayFieldMods
   local showDebug = args.showDebug

   local cErrors = {}
   local cUniqueErrorSet = {}

   reportError = function(msg)
      cErrors[#cErrors+1] = msg
   end

   reportErrorOnce = function(msg)
      cUniqueErrorSet[msg] = true
   end

   local songBaseBpm = GAMESTATE:GetCurrentSong():GetDisplayBpms()[2]

   -- Initialize mod table
   local modTable = {}
   local msgTable = {}
   do
      -- If the section name matches the song name, test only that section
      local sectionIdx = nil
      local songName = GAMESTATE:GetCurrentSong():GetDisplayMainTitle()
      for idx, section in ipairs(inputTable) do
         if songName == section.name then
            sectionIdx = idx
            break
         end
      end

      local easeFuncs = {
         Linear=easeLinear,
         SmoothStart2=easeSmoothStart2,
         SmoothStart3=easeSmoothStart3,
         SmoothStart4=easeSmoothStart4,
         SmoothEnd2=easeSmoothEnd2,
         SmoothEnd3=easeSmoothEnd3,
         SmoothEnd4=easeSmoothEnd4,
         SmoothStep2=easeSmoothStep2,
         SmoothStep3=easeSmoothStep3,
         SmoothStep4=easeSmoothStep4,
         Rebound2=easeRebound2,
         Rebound4=easeRebound4,
         SpringEnd=easeSpringEnd,
      }

      -- Converts perspective mod values to Skew, Tilt value pairs
      local kPerspectiveMods = {
         Distant=function(value) return 0, value end,
         Hallway=function(value) return 0, -value end,
         Incoming=function(value) return value, -value end,
         Overhead=function(value) return 0, 0 end,
         Space=function(value) return value, value end,
      }

      local function appendMod(beat, length, name, valueFrom, valueTo, ease, player, diffs)
         length = length or 0
         valueTo = valueTo or 1
         ease = type(ease) == 'string' and easeFuncs[ease] or ease or easeLinear
         if beat == 0 and length == 0 and ease(1) == 0 then
            -- Special case: Ignore init mods with an ease func that rebounds to 0
         else
            local _, pid = normalizePlayer(player)

            local p1Candidate = (not pid or pid == PLAYER_1) and GAMESTATE:IsPlayerEnabled(PLAYER_1)
            local p1Valid = p1Candidate and
               (not diffs or diffs[ToEnumShortString(GAMESTATE:GetCurrentSteps(0):GetDifficulty())])
            local p2Candidate = (not pid or pid == PLAYER_2) and GAMESTATE:IsPlayerEnabled(PLAYER_2)
            local p2Valid = p2Candidate and
               (not diffs or diffs[ToEnumShortString(GAMESTATE:GetCurrentSteps(1):GetDifficulty())])

            if not p1Valid and not p2Valid then
               -- Both players invalidated
               return
            end

            if p1Candidate and p2Candidate then
               if not p2Valid then
                  pid = PLAYER_1
               elseif not p1Valid then
                  pid = PLAYER_2
               end
            end

            local perspectiveMod = kPerspectiveMods[name]
            if perspectiveMod then
               local skewFrom, tiltFrom = nil, nil
               if valueFrom then
                  skewFrom, tiltFrom = perspectiveMod(valueFrom)
               end
               local skewTo, tiltTo = nil, nil
               if valueTo == 'user' then
                  skewTo, tiltTo = 'user', 'user'
               else
                  skewTo, tiltTo = perspectiveMod(valueTo)
               end

               modTable[#modTable+1] = {
                  beat=beat,
                  length=length,
                  name='Skew',
                  valueFrom=skewFrom,
                  valueTo=skewTo,
                  ease=ease,
                  pid=pid,
               }
               modTable[#modTable+1] = {
                  beat=beat,
                  length=length,
                  name='Tilt',
                  valueFrom=tiltFrom,
                  valueTo=tiltTo,
                  ease=ease,
                  pid=pid,
               }
            else
               if name == 'MMod' then
                  -- MMods are not settable during gameplay, so convert to an XMod
                  name = 'XMod'
                  if valueFrom then valueFrom = valueFrom / songBaseBpm end
                  if valueTo ~= 'user' then valueTo = valueTo / songBaseBpm end
               end

               modTable[#modTable+1] = {
                  beat=beat,
                  length=length,
                  name=name,
                  valueFrom=valueFrom,
                  valueTo=valueTo,
                  ease=ease,
                  pid=pid,
               }
            end
         end
      end

      local function appendMsg(beat, name)
         msgTable[#msgTable+1] = {
            beat=beat,
            name=name,
         }
      end

      local function mergeDiffs(allowDiff, blockDiff, minDiff, maxDiff)
         if not allowDiff and not blockDiff and not minDiff and not maxDiff then return nil end

         if not minDiff then minDiff = ToEnumShortString(Difficulty[1]) end
         if not maxDiff then maxDiff = ToEnumShortString(Difficulty[#Difficulty]) end

         local function matchDiff(d, diff)
            if type(diff) == 'string' then return d == diff end
            for _, v in ipairs(diff) do
               if d == v then return true end
            end
            return false
         end

         local diffs = {}
         local withinRange = false
         for _, d in ipairs(Difficulty) do
            local shortD = ToEnumShortString(d)
            if shortD == minDiff then withinRange = true end
            if withinRange and
               (not allowDiff or matchDiff(shortD, allowDiff)) and
               (not blockDiff or not matchDiff(shortD, blockDiff))
            then
               diffs[shortD] = true
            end
            if shortD == maxDiff then break end
         end

         return diffs
      end

      if not sectionIdx then
         -- Full song, so load the full mod table
         local lengthSoFar = 0
         for idx, section in ipairs(inputTable) do
            for _, group in ipairs(section.groups) do
               if not group.test then
                  for _, mod in ipairs(group.mods) do
                     local beat = mod.beat + lengthSoFar + group.offset
                     if mod.msg then
                        appendMsg(beat, mod.msg)
                     elseif mod.mod then
                        local player = mod.player or group.player
                        local diffs = mergeDiffs(
                           mod.diff or group.diff,
                           mod.notDiff or group.notDiff,
                           mod.minDiff or group.minDiff,
                           mod.maxDiff or group.maxDiff)
                        appendMod(beat, mod.len, mod.mod, mod.from, mod.val, mod.ease, player, diffs)
                     else
                        --Error?
                     end
                  end
               end
            end
            lengthSoFar = lengthSoFar + section.length
         end
      else
         -- Initialize end state from mods of all previous sections.
         -- (Messages are ignored; fire the message manually in a test group if you need this.)
         -- TODO: Support mid-interp values.
         -- TODO: Account for beats in case table is out-of-order.
         local initModTable = {}
         for i = 1, sectionIdx-1 do
            for _, group in ipairs(inputTable[i].groups) do
               for _, mod in ipairs(group.mods) do
                  if mod.mod then
                     initModTable[mod.mod] = { group=group, mod=mod }
                  end
               end
            end
         end

         for initModName, initMod in pairs(initModTable) do
            if initMod.mod.value ~= 0 then
               local player = initMod.mod.player or initMod.group.player
               local diffs = mergeDiffs(
                  initMod.mod.diff or initMod.group.diff,
                  initMod.mod.notDiff or initMod.group.notDiff,
                  initMod.mod.minDiff or initMod.group.minDiff,
                  initMod.mod.maxDiff or initMod.group.maxDiff)
               appendMod(0, 0, initModName, nil, initMod.mod.val, initMod.mod.ease, player, diffs)
            end
         end

         -- Collect mods only for this section
         local section = inputTable[sectionIdx]
         for _, group in ipairs(section.groups) do
            for _, mod in ipairs(group.mods) do
               local beat = mod.beat + section.rebase + group.offset
               if mod.msg then
                  appendMsg(beat, mod.msg)
               elseif mod.mod then
                  local player = mod.player or group.player
                  local diffs = mergeDiffs(
                     mod.diff or group.diff,
                     mod.notDiff or group.notDiff,
                     mod.minDiff or group.minDiff,
                     mod.maxDiff or group.maxDiff)
                  appendMod(beat, mod.len, mod.mod, mod.from, mod.val, mod.ease, player, diffs)
               else
                  --Error?
               end
            end
         end
      end

      table.sort(modTable, function(left, right)
         return left.beat < right.beat or left.beat == right.beat and left.length < right.length
      end)
      table.sort(msgTable, function(left, right)
         return left.beat < right.beat
      end)
   end

   local cState = {
      modTable=modTable,
      modNextIdx=1,
      msgTable=msgTable,
      msgNextIdx=1,
      players={},
      prevSongBeat=-1,
   }

   for pidx, pid in ipairs({PLAYER_1, PLAYER_2}) do
      if GAMESTATE:IsPlayerEnabled(pid) then
         cState.players[pid] = {
            idx=pidx,
            options=nil,
            playField=nil,
            basePos=nil,
            userMods={},
            userSpeedMod=nil,
            userSpeedValue=nil,
            activeMods={},
            debugModTargets={},
         }
      end
   end

   getVar = function(name, pid)
      _, pid = normalizePlayer(pid)

      local player = nil
      if pid then
         player = cState.players[pid]
      else
         for _, firstPlayer in pairs(cState.players) do
            player = firstPlayer
            break
         end
      end

      if not player or not player.options then
         return 0
      end

      local value = 0
      if not player.options[name] then
         local activeMod = player.activeMods[name]
         if activeMod and activeMod.value then
            value = activeMod.value
         end
      end
      return value
   end

   local kBinaryMods = {
      AttackMines=true,
      Backwards=true,
      Big=true,
      BMRize=true,
      Cosecant=true,
      DizzyHolds=true,
      Echo=true,
      Floored=true,
      HoldRolls=true,
      Left=true,
      Little=true,
      Mines=true,
      Mirror=true,
      MuteOnError=true,
      NoFakes=true,
      NoHands=true,
      NoHolds=true,
      NoJumps=true,
      NoLifts=true,
      NoMines=true,
      NoQuads=true,
      NoRolls=true,
      NoStretch=true,
      Overhead=true,
      Planted=true,
      Quick=true,
      Right=true,
      Shuffle=true,
      Skippy=true,
      SoftShuffle=true,
      StealthPastReceptors=true,
      StealthType=true,
      Stomp=true,
      SuperShuffle=true,
      TurnNone=true,
      Twister=true,
      Wide=true,
      ZBuffer=true,
   }

   local kPlayFieldModFuncs = {
      PF_MoveX=function(player, value) player.playField:x(player.basePos.x + value) end,
      PF_MoveY=function(player, value) player.playField:y(player.basePos.y + value) end,
      PF_MoveZ=function(player, value) player.playField:z(player.basePos.z + value) end,
      PF_RotateX=function(player, value) player.playField:rotationx(value) end,
      PF_RotateY=function(player, value) player.playField:rotationy(value) end,
      PF_RotateZ=function(player, value) player.playField:rotationz(value) end,
      PF_Zoom=function(player, value) player.playField:zoom(1 + value) end,
      PF_ZoomX=function(player, value) player.playField:basezoomx(1 + value) end,
      PF_ZoomY=function(player, value) player.playField:basezoomy(1 + value) end,
      PF_ZoomZ=function(player, value) player.playField:basezoomz(1 + value) end,
      PF_SkewX=function(player, value) player.playField:skewx(value) end,
   }

   --[[
   for name, children in pairs(SCREENMAN:GetTopScreen():GetChildren()) do
      reportError(name)
      if name == '' then
         local actors = type(children) == 'table' and children or { children }
         for _, actor in ipairs(actors) do
            for innerName, innerChildren in pairs(actor:GetChildren()) do
               reportError(':' .. innerName)
            end
         end
      end
   end
   --]]

   local function update(self)
      local function isDynamicSpeedMod(mod)
         -- Excludes MMod because it can't be set during gameplay
         return mod == 'XMod' or mod == 'CMod'
      end

      local function getSpeedMod(options)
         local cMod = options:CMod()
         if cMod then return 'CMod', cMod end
         local mMod = options:MMod()
         if mMod then return 'MMod', mMod end
         return 'XMod', options:XMod()
      end

      local function convertSpeedMod(fromMod, fromValue, toMod)
         if fromMod == 'XMod' and toMod ~= 'XMod' then
            return fromValue * songBaseBpm
         elseif fromMod ~= 'XMod' and toMod == 'XMod' then
            return fromValue / songBaseBpm
         else
            return fromValue
         end
      end

      for pid, player in pairs(cState.players) do
         if not player.options then
            player.options = GAMESTATE:GetPlayerState(player.idx-1):GetPlayerOptions('ModsLevel_Song')

            local userModNames = {}
            for _, mod in pairs(cState.modTable) do
               if mod.valueTo == 'user' and not isDynamicSpeedMod(mod.name) then
                  userModNames[mod.name] = true
               end
            end

            for userModName, _ in pairs(userModNames) do
               player.userMods[userModName] = player.options[userModName](player.options)
            end

            player.userSpeedMod, player.userSpeedValue = getSpeedMod(player.options)
            if player.userSpeedMod == 'MMod' then
               player.userSpeedMod = 'XMod'
               player.userSpeedValue = convertSpeedMod('MMod', player.userSpeedValue, 'XMod')
               --player.options:XMod(player.userSpeedValue, 10000)
            end
         end

         if not player.playField then
            local topScreen = SCREENMAN:GetTopScreen()
            player.playField = topScreen:GetChild('PlayerP'..player.idx)

            -- Get edit mode playfield
            if not player.playField and
               usePlayFieldMods and
               pid == PLAYER_1 and
               GAMESTATE:IsPlayerEnabled(PLAYER_1) and
               topScreen:GetChild('EditHelp')
            then
               local playFieldCandidates = topScreen:GetChild('')
               if type(playFieldCandidates) ~= 'table' then
                  playFieldCandidates = { playFieldCandidates }
               end

               for _, actor in ipairs(playFieldCandidates) do
                  if actor:GetChild('NoteField') then
                     player.playField = actor
                     break
                  end
               end
            end
         end

         if player.playField and not player.basePos then
            player.basePos = {
               x=player.playField:GetX(),
               y=player.playField:GetY(),
               z=player.playField:GetZ(),
            }
         end
      end

      local songBeat =
         useRealTime and
            math.max(0, GAMESTATE:GetCurMusicSeconds() / 60 * songBaseBpm) or
            GAMESTATE:GetSongBeat()
      if songBeat > cState.prevSongBeat then
         -- Process mod table
         while cState.modNextIdx <= #cState.modTable do
            local mod = cState.modTable[cState.modNextIdx]
            if songBeat < mod.beat then break end
            cState.modNextIdx = cState.modNextIdx + 1

            for pid, player in pairs(cState.players) do
               if not mod.pid or mod.pid == pid then
                  local modValueFrom = mod.valueFrom
                  if not modValueFrom then
                     local activeMod = player.activeMods[mod.name]
                     if activeMod then
                        modValueFrom = activeMod:eval(songBeat)
                     elseif player.options[mod.name] then
                        if isDynamicSpeedMod(mod.name) then
                           local speedMod, speedValue = getSpeedMod(player.options)
                           modValueFrom = convertSpeedMod(speedMod, speedValue, mod.name)
                        else
                           modValueFrom = player.options[mod.name](player.options)
                           if kBinaryMods[mod.name] and modValueFrom then
                              modValueFrom = 1
                           end
                        end
                     end

                     modValueFrom = modValueFrom or 0
                  end

                  local modValueTo = mod.valueTo
                  if modValueTo == 'user' then
                     if isDynamicSpeedMod(mod.name) then
                        modValueTo = convertSpeedMod(player.userSpeedMod, player.userSpeedValue, mod.name)
                     else
                        modValueTo = player.userMods[mod.name]
                     end
                  end

                  if modValueFrom ~= modValueTo then
                     player.activeMods[mod.name] = {
                        beatStart=mod.beat,
                        beatEnd=mod.beat + mod.length,
                        valueFrom=modValueFrom,
                        valueTo=modValueTo,
                        ease=mod.ease,
                        eval=function(self, beat)
                           local t = nil
                           if beat >= self.beatEnd then
                              t = 1
                           elseif beat <= self.beatStart then
                              t = 0
                           else
                              t = (beat - self.beatStart) / (self.beatEnd - self.beatStart)
                           end

                           return easeLerp(self.ease(t), self.valueFrom, self.valueTo)
                        end,
                     }

                     player.debugModTargets[mod.name] = modValueTo
                  end
               end
            end
         end

         -- Update active mods
         for pid, player in pairs(cState.players) do
            for modName, activeMod in pairs(player.activeMods) do
               if player.options[modName] then
                  local modValue = activeMod:eval(songBeat)
                  if kBinaryMods[modName] then
                     player.options[modName](player.options, modValue ~= 0 and true or false)
                  elseif isDynamicSpeedMod(modName) then
                     player.options[player.userSpeedMod](player.options,
                        convertSpeedMod(modName, modValue, player.userSpeedMod),
                        10000)
                  else
                     player.options[modName](player.options, modValue, 10000)
                  end
               else
                  activeMod.value = activeMod:eval(songBeat)
               end

               if songBeat >= activeMod.beatEnd then
                  player.debugModTargets[modName] = activeMod:eval(activeMod.beatEnd)
                  if player.debugModTargets[modName] == 0 then
                     player.debugModTargets[modName] = nil
                  end
               end
            end
         end

         -- Update play field mods
         if usePlayFieldMods then
            for pid, player in pairs(cState.players) do
               if player.playField then
                  player.playField:sleep(0)
                  for modName, func in pairs(kPlayFieldModFuncs) do
                     func(player, getVar(modName, pid))
                  end
               end
            end
         end

         -- Process messages
         while cState.msgNextIdx <= #cState.msgTable do
            local msg = cState.msgTable[cState.msgNextIdx]
            if songBeat < msg.beat then break end
            cState.msgNextIdx = cState.msgNextIdx + 1

            MESSAGEMAN:Broadcast(msg.name)
         end

         cState.prevSongBeat = songBeat
      end
   end

   local frame = Def.ActorFrame{
      InitCommand=function(self) self:SetUpdateFunction(update) end,
      Def.Quad{
         InitCommand=function(self) self:visible(false) end,
         OnCommand=function(self) self:sleep(9999) end
      }
   }

   if showDebug then
      local function debugUpdate(self)
         local debugStr = ''

         for _, msg in ipairs(cErrors) do
            debugStr = debugStr .. 'ERROR: ' .. msg .. '\n'
         end

         for msg, _ in pairs(cUniqueErrorSet) do
            debugStr = debugStr .. 'ERROR: ' .. msg .. '\n'
         end

         if not cState.modTable or not cState.msgTable then
            debugStr = debugStr .. 'Waiting on mod table initialization\n'
         else
            debugStr = debugStr .. 'Beat: ' .. math.floor(GAMESTATE:GetSongBeat())

            local player = cState.players[PLAYER_1]
            local sortedModNames = {{},{},{}}
            for modName, _ in pairs(player.debugModTargets) do
               local group = nil
               if kBinaryMods[modName] then
                  group = sortedModNames[2]
               elseif modName == 'XMod' or modName == 'CMod' then
                  group = sortedModNames[1]
               else
                  group = sortedModNames[3]
               end

               group[#group+1] = modName
            end

            for _, group in ipairs(sortedModNames) do
               table.sort(group)
            end

            for _, group in ipairs(sortedModNames) do
               for _, modName in ipairs(group) do
                  local debugModTarget = player.debugModTargets[modName]
                  local lineStr = nil
                  if not player.options[modName] then
                     lineStr = debugModTarget .. ' ' .. modName
                  elseif kBinaryMods[modName] then
                     lineStr = (debugModTarget == 0 and 'No ' or '') .. modName
                  elseif modName == 'XMod' then
                     lineStr = debugModTarget .. 'x'
                  elseif modName == 'CMod' then
                     lineStr = 'C' .. debugModTarget
                  else
                     lineStr = (debugModTarget*100) .. '% ' .. modName
                  end

                  debugStr = debugStr .. '\n' ..lineStr
               end
            end
         end

         self:GetChild("DebugText"):settext(debugStr)
      end

      frame[#frame+1] = Def.ActorFrame{
         InitCommand=function(self) self:SetUpdateFunction(debugUpdate) end,
         Def.BitmapText{
            Name="DebugText",
            Font="Common normal",
            InitCommand=function(self)
               self:x(SCREEN_WIDTH*3/4)
               self:y(SCREEN_CENTER_Y)
            end,
         }
      }
   end

   return frame
end
