-- Copyright (C) 2010 Tyson Brown
-- 
-- This file is part QuestHelper2, an AddOn for World of Warcraft.
-- 
-- QuestHelper2 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.
-- 
-- QuestHelper2 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 QuestHelper2. If not, see <http://www.gnu.org/licenses/>.

if LibDebug then LibDebug() end
local shared = select(2, ...)

_G.QuestHelper2_Collection = shared

local insert, remove, wipe = table.insert, table.remove, table.wipe

-- When loading data, we often need to restore the metatables that didn't get saved with the
-- rest of the data (and with good reason); so for convenience, we store any important
-- meta tables here for easy reference.
shared.meta = setmetatable({}, {
  __index = function (meta, key)
    error("Unknown metatable: "..tostring(key))
  end,

  __newindex = function (meta, key, value)
    assert(type(key) == "string")
    assert(type(value) == "table")
    assert(value.name == key)
    assert(value.__index or value.__newindex)
    rawset(meta, key, value)
  end
})

shared.static = {}

local frame = CreateFrame("Frame", nil, nil)

local on_load = {}

function shared.onLoad(cb, ...)
  if on_load then insert(on_load, shared.callback(cb, ...))
  else cb(...) end
end

local events = {}

function shared.onEvent(event, cb, ...)
  local list = events[event]
  if not list then
    frame:RegisterEvent(event)
    events[event] = {shared.callback(cb, ...)}
  else
    insert(list, shared.callback(cb, ...))
  end
end

function shared.onSave(cb, ...)
  shared.onEvent("PLAYER_LOGOUT", cb, ...)
end

-- Events to be scheduled are added here, in order from most urgent to least urgent.
local schedule = {}

-- When the scheduleHandler is running, delay_schedule is made true
-- and events are added to schedule_delayed instead of schedule.
-- This is to prevent schedule from being modified while we're iterating
-- through it.
local delay_schedule = false

-- Unlike schedule, this table isn't sorted.
local schedule_delayed = {}

-- Funtion defined later; has a circular dependency with scheduleHandler.
local scheduleEvent

local callback_meta = {
  __call = function (self, ...)
    local base = self.args
    local extra = select("#", ...)
    
    if extra == 0 then
      -- No extra arguments, invoke the callback normally.
      return self.cb(unpack(self, 1, base))
    elseif base == 0 then
      -- No base arguments, pass the extra arguments normally.
      return self.cb(...)
    else
      -- We have base arguments and extra arguments.
      -- Time for some fancy footwork.
      
      for i=1,extra do
        -- Append any passed arguments to the argument list.
        self[base+i] = select(i, ...)
      end
      
      local function cleanupAndReturn(...)
        for i=1,extra do
          -- Remove the extra elements we added, to stop from inadvertently
          -- preventing them from being garbage collected.
          self[base+i] = nil
        end
        
        -- Return whatever the callback returned.
        return ...
      end
      
      return cleanupAndReturn(self.cb(unpack(self, 1, base+extra)))
    end
  end
}

-- This returns something that will invoke cb with the passed arguments.
-- Possibly just cb if there are no arguments.
function shared.callback(cb, ...)
  local arg_count = select("#", ...)
  
  -- Nothing special is required.
  if arg_count == 0 then
    return cb
  end
  
  -- Stick the extra arguments into a table, and give it a special metatable
  -- to make the table executable.
  return setmetatable({cb=cb, args=arg_count, ...}, callback_meta)
end

-- Whenever invokeCallbackList starts iterating, it maps the list to false.
-- If it is invoked on a list that is already in the set, it returns without executing it.
-- removeCallbackListElement will fill this with the indicies of any elements that were requested
-- to be removed.
local active_callback_iterations = {}

-- Helper function for removing indicies from list.
local function removeIndicies(list, set, cur_index, callback_count)
  local sorted, index_count = {}, 0
  
  for index in pairs(set) do
    index_count = index_count + 1
    sorted[index_count] = index
  end
  
  table.sort(sorted)
  
  -- We need to remove the indices in reverse order, so that removing the element
  -- doesn't shift the indices of any of the other elements we're removing.
  for i=index_count,1,-1 do
    local index = sorted[i]
    
    assert(list[index])
    
    if index < cur_index then
      -- Removing this index will alter the current index due to element shifting.
      cur_index = cur_index - 1
    end
    
    remove(list, index)
  end
  
  return cur_index, callback_count - index_count
end

-- Safely invokes all the callbacks in a list, without letting
-- any errors they may generate from screwing things up too badly.
function shared.invokeCallbackList(list, ...)
  local callback_count = #list
  if callback_count == 0 then
    -- There are no callbacks to execute; return.
    return
  end
  
  local arg_count = select("#", ...)
  local error_func = geterrorhandler()
  
  if active_callback_iterations[list] ~= nil then
    -- We are already iterating through this list.
    return
  end
  
  active_callback_iterations[list] = false
  
  if arg_count > 0 then
    -- Need to create a wrapper so that we can pass the arguments to the callback
    -- through xpcall.
    local cb, args = nil, {...}
    local function wrapper() cb(unpack(args,1,arg_count)) end
    
    local i = 1
    while i <= callback_count do
      cb = list[i]
      xpcall(wrapper, error_func)
      i = i + 1
      
      local state = active_callback_iterations[list]
      if state ~= false then
        -- Some indices were slated for removal.
        i, callback_count = removeIndicies(list, state, i, callback_count)
        active_callback_iterations[list] = false
      end
    end
  else
    local i = 1
    while i <= callback_count do
      xpcall(list[i], error_func)
      i = i + 1
      
      local state = active_callback_iterations[list]
      if state ~= false then
        -- Some indices were slated for removal.
        i, callback_count = removeIndicies(list, state, i, callback_count)
        active_callback_iterations[list] = false
      end
    end
  end
  
  -- This list is no longer in use.
  active_callback_iterations[list] = nil
end

-- Removes a callback from a callback list that may currently be in used by invokeCallbackList.
-- Returns the number of elements that this call affected, and whether or not this call
-- caused all callbacks in list to become scheduled for removal when invokeCallbackList finishes.
-- 
-- On a related note, adding an element to a list that is in use by invokeCallbackList should be safe,
-- however, that added callback won't get invoked.
function shared.removeCallbackListCallback(list, callback)
  local state = active_callback_iterations[list]
  
  local index, count = nil, 0
  
  if state == nil then
    -- List isn't being used, remove the callbacks directly.
    
    for i = #list,1,-1 do
      local cb = list[1]
      if cb == callback or (type(cb) == "table" and getmetatable(cb) == callback_meta and cb.cb == callback) then
        remove(list, i)
        count = count + 1
      end
    end
    
    return count, count ~= 0 and list[1] == nil
  end
  
  -- The list is in use! Oh no!
  
  if state == false then
    -- Create a table to store the index in.
    state = {}
    active_callback_iterations[list] = state
  end
  
  -- Record that this index needs to be removed.
  for i, cb in ipairs(list) do
    if cb == callback or (type(cb) == "table" and getmetatable(cb) == callback_meta and cb.cb == callback) then
      if not state[i] then
        count = count + 1
        state[i] = true
      end
    end
  end
  
  if count > 0 then
    for i in pairs(list) do
      if not state[i] then
        -- This index hasn't been scheduled for removal, therefore the list won't become empty when invokeCallbackList finishes.
        return count, false
      end
    end
    
    
    return count, true
  else
    -- No changes were made.
    return 0, false
  end
end

function shared.cancelEvent(event, cb)
  local list = events[event]
  
  if list then
    local count, empty = shared.removeCallbackListCallback(list, cb)
    
    if empty then
      frame:UnregisterEvent(event)
      events[event] = nil
    end
    
    return count
  end
  
  return 0
end

local call_once = {}

local function scheduleHandler()
  local now = GetTime()
  
  local event = schedule[1]
  
  -- We're busy, go away.
  delay_schedule = true
  
  --[===[@debug@
  local event_count, event_start_time = 0, GetTime()
  --@end-debug@]===]
  
  -- Invoke all the events that are past their scheduled time.
  while event and now >= event.when do
    --[===[@debug@
    --print(" >>> ", event.origin)
    event_count = event_count + 1
    --@end-debug@]===]
    
    remove(schedule, 1)
    
    local cb = event.cb
    
    local was_call_once = call_once[cb]
    call_once[cb] = nil
    
    local success, delay = xpcall(event, geterrorhandler())
    
    -- Only reschedule the callback if it's not in the call_once set.
    if success then
      if type(delay) == "number" and not call_once[cb] then
        -- If it was in the call_once set when we started, add it back again.
        call_once[cb] = was_call_once
        event.when = event.when + delay
        scheduleEvent(event)
      end
    else
      -- xpcall actually set delay to an error message rather than the callback delay.
      print("Error in callback: ", delay)
    end
    
    event = schedule[1]
  end
  
  -- Okay, we're done, come back.
  delay_schedule = false
  
  --[===[@debug@
  local elapsed = (GetTime()-event_start_time)*1000
  if event_count > 0 then
    --print(("Processed %d scheduled events over %.0fms; %.0fms/event."):format(event_count, elapsed, elapsed/event_count))
  end
  --@end-debug@]===]
  
  if next(schedule_delayed) then
    -- Some stuff re-added itself while we were busy.
    for i, event in ipairs(schedule_delayed) do
      scheduleEvent(event)
    end
    
    wipe(schedule_delayed)
  elseif event == nil then
    -- Nothing left to watch.
    frame:SetScript("OnUpdate", nil)
  end
end

scheduleEvent = function (event)
  if delay_schedule then
    insert(schedule_delayed, event)
  elseif #schedule == 0 then
    schedule[1] = event
    frame:SetScript("OnUpdate", scheduleHandler)
  else
    -- Perform a binary search on the schedule to figure out where to place the new item.
    -- The schedule must remain sorted at all times.
    local when, mn, mx = event.when, 1, #schedule+1
    
    while mn ~= mx do
      local mid = math.floor((mn+mx)/2)
      if when < schedule[mid].when then
        mx = mid
      else
        mn = mid+1
      end
    end
    
    insert(schedule, mn, event)
  end
end

function shared.onDelay(delay, cb, ...)
  scheduleEvent(setmetatable({
    --[===[@debug@
    origin=debugstack(2,1,0),
    --@end-debug@]===]
    when=GetTime()+delay,
    cb=cb,
    args=select("#", ...),
    ...
  }, callback_meta))
end

function shared.cancelDelayed(cb)
  local count = 0
  
  for i = #schedule_delayed,1,-1 do
    if schedule_delayed[i].cb == cb then
      remove(schedule_delayed, i)
      count = count + 1
    end
  end
  
  for i = #schedule,1,-1 do
    if schedule[i].cb == cb then
      remove(schedule, i)
      count = count + 1
    end
  end
  
  -- Eligible to be queued again by callOnce()
  call_once[cb] = nil
  
  return count
end

-- Schedules cb the be invoked once within the next delay seconds.
-- Any additional calls to callOnce involving cb within that time period get
-- ignored.
function shared.callOnce(cb, delay, ...)
  if call_once[cb] then
    -- Apparently the callback is already scheduled to be called, so nevermind.
  else
    call_once[cb] = true
    shared.onDelay(delay or 0, cb, ...)
  end
end

local in_combat = InCombatLockdown() and true or false

local on_combat_leave, on_combat_enter = {}, {}
local delayed_combat_action = {}

-- Invokes cb when entering the combat lockdown.
function shared.onCombatEnter(cb, ...)
  table.insert(on_combat_enter, shared.callback(cb, ...))
  -- Not invoking cb if we're currently in combat,
  -- since the point if this was probably to do unsafe things
  -- before the combat lockdown went into effect;
  -- which it already would be in that case.
end

-- Invokes cb when leaving the combat lockdown.
function shared.onCombatLeave(cb, ...)
  cb = shared.callback(cb, ...)
  table.insert(on_combat_leave, cb)
  if not in_combat then cb() end
end

-- Invokes cb with the passed arguments when we leave
-- the combat lockdown; or immediately if the lockdown
-- isn't currently in effect.
function shared.whenOutOfCombat(cb, ...)
  if in_combat then
    table.insert(delayed_combat_action, shared.callback(cb, ...))
  else
    -- Invoke right away.
    cb(...)
  end
end

shared.onEvent("PLAYER_REGEN_DISABLED", function ()
  assert(not in_combat)
  in_combat = true
  
  shared.invokeCallbackList(on_combat_enter)
end)

shared.onEvent("PLAYER_REGEN_ENABLED", function ()
  assert(in_combat)
  in_combat = false
  
  shared.invokeCallbackList(on_combat_leave)
  shared.invokeCallbackList(delayed_combat_action)
  table.wipe(delayed_combat_action)
end)

-- This function doesn't get used as the event handler until we're done processing our OnLoad event.
local function eventHandler(self, event, ...)
  shared.invokeCallbackList(events[event], ...)
end

local function startup()
  -- Assign the new event handler.
  frame:SetScript("OnEvent", eventHandler)
  
  -- This function is no more.
  startup = nil
  
  -- Make table nil so that none of the callbacks call onLoad and
  -- modify the list while we're iterating through it.
  local list = on_load
  on_load = nil
  
  -- Invoke everything that is supposed to be called when our variables are loaded.
  shared.invokeCallbackList(list)
  
  -- Spam!
  print("QuestHelper2_Collection Loaded!")
end

-- Wait for our ADDON_LOADED event to get triggered
-- before we start the ball rolling.
frame:RegisterEvent("ADDON_LOADED")
frame:SetScript("OnEvent", function (self, event, ...)
  if "ADDON_LOADED" == event and "QuestHelper2_Collection" == ... then
    if not events[event] then
      self:UnregisterEvent(event)
    end
    
    shared.displayFirstTimeNotice(startup)
  end
  
  -- Even though we're not loaded yet, do continue to alert peoples of
  -- events.
  if events[event] then
    shared.invokeCallbackList(events[event], ...)
  end
end)
