--- = Background =
-- Blizzard's IsSpellInRange API has always been very limited - you either must have the name of the spell, or its spell book ID. Checking directly by spellID is simply not possible.
-- Now, in Mists of Pandaria, Blizzard changed the way that many talents and specialization spells work - instead of giving you a new spell when leaned, they replace existing spells. These replacement spells do not work with Blizzard's IsSpellInRange function whatsoever; this limitation is what prompted the creation of this lib.
-- = Usage = 
-- **LibSpellRange-1.0** exposes an enhanced version of IsSpellInRange that:
-- * Allows ranged checking based on both spell name and spellID.
-- * Works correctly with replacement spells that will not work using Blizzard's IsSpellInRange method alone.
--
-- @class file
-- @name LibSpellRange-1.0.lua

local major = "SpellRange-1.0"
local minor = 22

assert(LibStub, format("%s requires LibStub.", major))

local Lib = LibStub:NewLibrary(major, minor)
if not Lib then return end

local tonumber = _G.tonumber
local strlower = _G.strlower
local wipe = _G.wipe
local type = _G.type
local select = _G.select

local GetSpellBookItemInfo = _G.GetSpellBookItemInfo or _G.C_SpellBook.GetSpellBookItemType
local GetSpellBookItemName = _G.GetSpellBookItemName or _G.C_SpellBook.GetSpellBookItemName
local GetSpellLink = _G.GetSpellLink or _G.C_Spell.GetSpellLink
local GetSpellInfo = _G.GetSpellInfo or _G.C_Spell.GetSpellName

local IsSpellInRange = _G.IsSpellInRange or function(id, unit)
  local result = C_Spell.IsSpellInRange(id, unit)
  if result == true then
    return 1
  elseif result == false then
    return 0
  end
  return nil
end
local isTWWSpellInRange = IsSpellInRange ~= _G.IsSpellInRange
local IsSpellBookItemInRange = _G.IsSpellInRange or function(index, spellBank, unit)
  local result = C_SpellBook.IsSpellBookItemInRange(index, spellBank, unit)
  if result == true then
    return 1
  elseif result == false then
    return 0
  end
  return nil
end

local SpellHasRange = _G.SpellHasRange or _G.C_Spell.SpellHasRange
local isTWWSpellHasRange = SpellHasRange ~= _G.SpellHasRange
local SpellBookHasRange = _G.SpellHasRange or _G.C_SpellBook.IsSpellBookItemInRange

local UnitExists = _G.UnitExists
local GetPetActionInfo = _G.GetPetActionInfo
local UnitIsUnit = _G.UnitIsUnit

local playerBook = _G.GetSpellBookItemName and "spell" or _G.Enum.SpellBookSpellBank.Player
local petBook = _G.GetSpellBookItemName and "pet" or _G.Enum.SpellBookSpellBank.Pet

-- isNumber is basically a tonumber cache for maximum efficiency
Lib.isNumber = Lib.isNumber or setmetatable({}, {
	__mode = "kv",
	__index = function(t, i)
		local o = tonumber(i) or false
		t[i] = o
		return o
end})
local isNumber = Lib.isNumber

-- strlower cache for maximum efficiency
Lib.strlowerCache = Lib.strlowerCache or setmetatable(
{}, {
	__index = function(t, i)
		if not i then return end
		local o
		if type(i) == "number" then
			o = i
		else
			o = strlower(i)
		end
		t[i] = o
		return o
	end,
}) local strlowerCache = Lib.strlowerCache

-- Matches lowercase player spell names to their spellBookID
Lib.spellsByName_spell = Lib.spellsByName_spell or {}
local spellsByName_spell = Lib.spellsByName_spell

-- Matches player spellIDs to their spellBookID
Lib.spellsByID_spell = Lib.spellsByID_spell or {}
local spellsByID_spell = Lib.spellsByID_spell

-- Matches lowercase pet spell names to their spellBookID
Lib.spellsByName_pet = Lib.spellsByName_pet or {}
local spellsByName_pet = Lib.spellsByName_pet

-- Matches pet spellIDs to their spellBookID
Lib.spellsByID_pet = Lib.spellsByID_pet or {}
local spellsByID_pet = Lib.spellsByID_pet

-- Matches pet spell names to their pet action bar slot
Lib.actionsByName_pet = Lib.actionsByName_pet or {}
local actionsByName_pet = Lib.actionsByName_pet

-- Matches pet spell IDs to their pet action bar slot
Lib.actionsById_pet = Lib.actionsById_pet or {}
local actionsById_pet = Lib.actionsById_pet

-- Caches whether a pet spell has been observed to ever have had a range.
-- Since this should never change for any particular spell,
-- it is not wiped.
Lib.petSpellHasRange = Lib.petSpellHasRange or {}
local petSpellHasRange = Lib.petSpellHasRange

-- Updates spellsByName and spellsByID

local GetNumSpellTabs = _G.GetNumSpellTabs or C_SpellBook.GetNumSpellBookSkillLines
local GetSpellTabInfo = _G.GetSpellTabInfo or function(index)
	local skillLineInfo = C_SpellBook.GetSpellBookSkillLineInfo(index);
	if skillLineInfo then
		return	skillLineInfo.name,
				skillLineInfo.iconID,
				skillLineInfo.itemIndexOffset,
				skillLineInfo.numSpellBookItems,
				skillLineInfo.isGuild,
				skillLineInfo.offSpecID,
				skillLineInfo.shouldHide,
				skillLineInfo.specID;
	end
end

local function UpdateBook(bookType)
	local book = bookType == "spell" and playerBook or petBook
	local max = 0
	for i = 1, GetNumSpellTabs() do
		local _, _, offs, numspells, _, specId = GetSpellTabInfo(i)
		if specId == 0 then
			max = offs + numspells
		end
	end

	local spellsByName = Lib["spellsByName_" .. bookType]
	local spellsByID = Lib["spellsByID_" .. bookType]
	
	wipe(spellsByName)
	wipe(spellsByID)
	
	for spellBookID = 1, max do
		local type, baseSpellID = GetSpellBookItemInfo(spellBookID, book)
		
		if type == "SPELL" or type == "PETACTION" then
			local currentSpellName, _, currentSpellID = GetSpellBookItemName(spellBookID, book)
			if not currentSpellID then
				local link = GetSpellLink(currentSpellName)
				currentSpellID = tonumber(link and link:gsub("|", "||"):match("spell:(%d+)"))
			end

			-- For each entry we add to a table,
			-- only add it if there isn't anything there already.
			-- This prevents weird passives from overwriting real, legit spells.
			-- For example, in WoW 7.3.5 the ret paladin mastery 
			-- was coming back with a base spell named "Judgement",
			-- which was overwriting the real "Judgement".
			-- Passives usually come last in the spellbook,
			-- so this should work just fine as a workaround.
			-- This issue with "Judgement" is gone in BFA because the mastery changed.
			
			if currentSpellName and not spellsByName[strlower(currentSpellName)] then
				spellsByName[strlower(currentSpellName)] = spellBookID
			end
			if currentSpellID and not spellsByID[currentSpellID] then
				spellsByID[currentSpellID] = spellBookID
			end
			
			if type == "SPELL" then
				-- PETACTION (pet abilities) don't return a spellID for baseSpellID,
				-- so base spells only work for proper player spells.
				local baseSpellName = GetSpellInfo(baseSpellID)
				if baseSpellName and not spellsByName[strlower(baseSpellName)] then
					spellsByName[strlower(baseSpellName)] = spellBookID
				end
				if baseSpellID and not spellsByID[baseSpellID] then
					spellsByID[baseSpellID] = spellBookID
				end
			end
		end
	end
end

local function UpdatePetBar()
	wipe(actionsByName_pet)
	wipe(actionsById_pet)
	if not UnitExists("pet") then return end

	for i = 1, NUM_PET_ACTION_SLOTS do
		local name, texture, isToken, isActive, autoCastAllowed, autoCastEnabled, spellID, checksRange, inRange = GetPetActionInfo(i)
		if checksRange then
			actionsByName_pet[strlower(name)] = i
			actionsById_pet[spellID] = i

			petSpellHasRange[strlower(name)] = true
			petSpellHasRange[spellID] = true
		end
	end
end
UpdatePetBar()

-- Handles updating spellsByName and spellsByID
if not Lib.updaterFrame then
	Lib.updaterFrame = CreateFrame("Frame")
end
Lib.updaterFrame:UnregisterAllEvents()
Lib.updaterFrame:RegisterEvent("SPELLS_CHANGED")
Lib.updaterFrame:RegisterEvent("PET_BAR_UPDATE")
Lib.updaterFrame:RegisterEvent("PLAYER_TARGET_CHANGED")

local function UpdateSpells(_, event)
	if event == "PET_BAR_UPDATE" then
		UpdatePetBar()
	elseif event == "PLAYER_TARGET_CHANGED" then
		-- `checksRange` from GetPetActionInfo() changes based on whether the player has a target or not.
		UpdatePetBar()
	elseif event == "SPELLS_CHANGED" then
		UpdateBook("spell")
		UpdateBook("pet")
	end
end

Lib.updaterFrame:SetScript("OnEvent", UpdateSpells)
UpdateSpells()

--- Improved spell range checking function.
-- @name SpellRange.IsSpellInRange
-- @paramsig spell, unit
-- @param spell Name or spellID of a spell that you wish to check the range of. The spell must be a spell that you have in your spellbook or your pet's spellbook.
-- @param unit UnitID of the spell that you wish to check the range on.
-- @return Exact same returns as http://wowprogramming.com/docs/api/IsSpellInRange
-- @usage
-- -- Check spell range by spell name on unit "target"
-- local SpellRange = LibStub("SpellRange-1.0")
-- local inRange = SpellRange.IsSpellInRange("Stormstrike", "target")
--
-- -- Check spell range by spellID on unit "mouseover"
-- local SpellRange = LibStub("SpellRange-1.0")
-- local inRange = SpellRange.IsSpellInRange(17364, "mouseover")
function Lib.IsSpellInRange(spellInput, unit)
	if isNumber[spellInput] then
		local spell = spellsByID_spell[spellInput]
		if spell then
			return IsSpellBookItemInRange(spell, playerBook, unit)
		else
			local spell = spellsByID_pet[spellInput]
			if spell then
				local petResult = IsSpellBookItemInRange(spell, petBook, unit)
				if petResult ~= nil then
					return petResult
				end
				
				-- IsSpellInRange seems to no longer work for pet spellbook,
				-- so we also try the action bar API.
				local actionSlot = actionsById_pet[spellInput]
				if actionSlot and (unit == "target" or UnitIsUnit(unit, "target")) then
					return select(9, GetPetActionInfo(actionSlot)) and 1 or 0
				end
			end
		end
		if isTWWSpellInRange then
			return IsSpellInRange(spellInput, unit)
		else
			return IsSpellInRange(GetSpellInfo(spellInput), unit)
		end
	else
		local spellInput = strlowerCache[spellInput]
		
		local spell = spellsByName_spell[spellInput]
		if spell then
			return IsSpellBookItemInRange(spell, playerBook, unit)
		else
			local spell = spellsByName_pet[spellInput]
			if spell then
				local petResult = IsSpellBookItemInRange(spell, petBook, unit)
				if petResult ~= nil then
					return petResult
				end

				-- IsSpellInRange seems to no longer work for pet spellbook,
				-- so we also try the action bar API.
				local actionSlot = actionsByName_pet[spellInput]
				if actionSlot and (unit == "target" or UnitIsUnit(unit, "target")) then
					return select(9, GetPetActionInfo(actionSlot)) and 1 or 0
				end
			end
		end
		return IsSpellInRange(spellInput, unit)
	end
end


--- Improved SpellHasRange.
-- @name SpellRange.SpellHasRange
-- @paramsig spell
-- @param spell Name or spellID of a spell that you wish to check for a range. The spell must be a spell that you have in your spellbook or your pet's spellbook.
-- @return Exact same returns as http://wowprogramming.com/docs/api/SpellHasRange
-- @usage
-- -- Check if a spell has a range by spell name
-- local SpellRange = LibStub("SpellRange-1.0")
-- local hasRange = SpellRange.SpellHasRange("Stormstrike")
--
-- -- Check if a spell has a range by spellID
-- local SpellRange = LibStub("SpellRange-1.0")
-- local hasRange = SpellRange.SpellHasRange(17364)
function Lib.SpellHasRange(spellInput)
	if isNumber[spellInput] then
		local spell = spellsByID_spell[spellInput]
		if spell then
			return SpellBookHasRange(spell, playerBook)
		else
			local spell = spellsByID_pet[spellInput]
			if spell then
				-- SpellHasRange seems to no longer work for pet spellbook.
				return SpellBookHasRange(spell, petBook) or petSpellHasRange[spellInput] or false
			end
		end
		if isTWWSpellHasRange then
			return SpellHasRange(spellInput)
		else
			return SpellHasRange(GetSpellInfo(spellInput))
		end
	else
		local spellInput = strlowerCache[spellInput]
		
		local spell = spellsByName_spell[spellInput]
		if spell then
			return SpellBookHasRange(spell, playerBook)
		else
			local spell = spellsByName_pet[spellInput]
			if spell then
				-- SpellHasRange seems to no longer work for pet spellbook.
				return SpellBookHasRange(spell, petBook) or petSpellHasRange[spellInput] or false
			end
		end
		return SpellHasRange(spellInput)
	end
end