local app = select(2, ...);
local L = app.L;

-- App locals
local contains = app.contains;
local distance = app.distance;

-- Global locals
local coroutine, ipairs, pairs, pcall, rawget, tinsert, tonumber, math_floor
	= coroutine, ipairs, pairs, pcall, rawget, tinsert, tonumber, math.floor;
local CreateVector2D, GetInstanceInfo, GetRealZoneText, GetSubZoneText, GetZoneText, InCombatLockdown, IsInInstance
	= CreateVector2D, GetInstanceInfo, GetRealZoneText, GetSubZoneText, GetZoneText, InCombatLockdown, IsInInstance
local C_Map_GetMapInfo, C_Map_GetAreaInfo, C_Map_GetMapArtID, C_Map_GetMapLevels, C_Map_GetBestMapForUnit
	= C_Map.GetMapInfo, C_Map.GetAreaInfo, C_Map.GetMapArtID, C_Map.GetMapLevels, C_Map.GetBestMapForUnit;
local C_Map_GetPlayerMapPosition, C_Map_GetMapChildrenInfo, C_Map_GetWorldPosFromMapPos
	= C_Map.GetPlayerMapPosition, C_Map.GetMapChildrenInfo, C_Map.GetWorldPosFromMapPos;
local C_MapExplorationInfo_GetExploredAreaIDsAtPosition = C_MapExplorationInfo.GetExploredAreaIDsAtPosition;
local C_Map_GetMapInfoAtPosition = C_Map.GetMapInfoAtPosition or app.ReturnFalse	-- added in 8.0, can't use in Classic
local Callback = app.CallbackHandlers.Callback
local UNKNOWN = UNKNOWN

-- Map Name
local MapIDToMapName = L.MAP_ID_TO_ZONE_TEXT;
local function GetMapName(mapID)
	if mapID then
		local mapName = MapIDToMapName[mapID];
		if mapName then return mapName; end

		local info = C_Map_GetMapInfo(mapID);
		return (info and info.name) or ("Map ID #" .. mapID);
	else
		return "Map ID #???";
	end
end
app.GetMapName = GetMapName;

-- Player Position
app.GetPlayerPosition = function()
	local mapID = app.RealMapID;
	if mapID then
		local pos = C_Map_GetPlayerMapPosition(mapID, "player");
		if pos then
			local px, py = pos:GetXY();
			return mapID, px * 100, py * 100;
		end
	end
	return mapID, 50, 50, true
end

-- Current Map Detection
local CurrentMapID;
local function CalculateCurrentMapID()
	local originalMapID = C_Map_GetBestMapForUnit("player");
	app.RealMapID = originalMapID
	-- app.PrintDebug("RealMapID",originalMapID)
	if originalMapID then
		local remap = rawget(app.MapRemapping, originalMapID);
		if not remap then return originalMapID; end

		-- local info = C_Map_GetMapInfo(originalMapID);
		-- app.PrintDebug("CalculateCurrentMapID (original): ", originalMapID, info and info.name, not not remap);

		local substitutions = remap.artIDs;
		if substitutions then
			local artID = C_Map_GetMapArtID(originalMapID);
			if artID then
				local mapID = substitutions[artID];
				if mapID then
					--print(" SUBBED (artID): ", artID, mapID);
					return mapID;
				end
			end
		end

		local zoneTexts = {};
		local name = GetRealZoneText();
		if name and name:len() > 0 then
			zoneTexts[name] = 1;
		end
		name = GetSubZoneText();
		if name and name:len() > 0 then
			zoneTexts[name] = 1;
		end
		name = GetZoneText();
		if name and name:len() > 0 then
			zoneTexts[name] = 1;
		end
		local instanceName,instanceType = GetInstanceInfo();
		if instanceType and instanceType ~= "none" then
			zoneTexts[instanceName] = 1;
		end

		substitutions = remap.areaIDs;
		if substitutions then
			for areaID,mapID in pairs(substitutions) do
				local info = C_Map_GetAreaInfo(areaID);
				if info and zoneTexts[info] then
					-- app.PrintDebug(" SUBBED (areaID): ", areaID, info, mapID);
					return mapID;
				end
			end
		end
		substitutions = remap.names;
		if remap.isContinent and not remap.compiledList then
			remap.compiledList = true;
			local childMaps = C_Map_GetMapChildrenInfo(originalMapID);
			if childMaps then
				if not substitutions then
					substitutions = {};
					remap.names = substitutions;
				end
				for j,childMapInfo in ipairs(childMaps) do
					substitutions[childMapInfo.name] = childMapInfo.mapID;
					local subChildMaps = C_Map_GetMapChildrenInfo(childMapInfo.mapID);
					if subChildMaps then
						for k,subChildMapInfo in ipairs(subChildMaps) do
							substitutions[subChildMapInfo.name] = subChildMapInfo.mapID;
						end
					end
				end
			end
		end
		if substitutions then
			for name,mapID in pairs(substitutions) do
				if zoneTexts[name] then
					-- app.PrintDebug(" SUBBED (name): ", name, info, mapID);
					return mapID;
				end
			end
			if remap.isContinent then
				-- Attempt to find the closest map.
				local position = C_Map_GetPlayerMapPosition(originalMapID, "player");
				if position then
					local continentID, worldPosition = C_Map_GetWorldPosFromMapPos(originalMapID, position);
					local closestDistance, closestMapID = 99999999, nil;
					local px, py = worldPosition:GetXY();
					for _,mapID in pairs(substitutions) do
						position = C_Map_GetPlayerMapPosition(mapID, "player")
						if position then
							continentID, worldPosition = C_Map_GetWorldPosFromMapPos(mapID, position);
							if worldPosition then
								local dist = distance(px, py, worldPosition:GetXY());
								if dist < closestDistance then
									closestDistance = dist;
									closestMapID = mapID;
								end
							end
						end
					end
					if closestMapID then
						-- app.PrintDebug(" SUBBED (closest): ", closestMapID);
						return closestMapID;
					end
				end
			end
		end
	else
		local zoneTexts,substitutions = {}, nil;
		local name = GetRealZoneText();
		if name and name:len() > 0 then
			zoneTexts[name] = 1;
		end
		name = GetSubZoneText();
		if name and name:len() > 0 then
			zoneTexts[name] = 1;
		end
		name = GetZoneText();
		if name and name:len() > 0 then
			zoneTexts[name] = 1;
		end
		local instanceName,instanceType = GetInstanceInfo();
		if instanceType and instanceType ~= "none" then
			zoneTexts[instanceName] = 1;
		end
		for _,remap in pairs(app.MapRemapping) do
			for areaID,mapID in pairs(remap.areaIDs) do
				local info = C_Map_GetAreaInfo(areaID);
				if info and zoneTexts[info] then
					-- app.PrintDebug(" SUBBED (areaID): ", areaID, info, mapID);
					return mapID;
				end
			end
			for name,mapID in pairs(remap.names) do
				if zoneTexts[name] then
					-- app.PrintDebug(" SUBBED (name): ", name, info, mapID);
					return mapID;
				end
			end
		end
	end
	return originalMapID;
end
local EmptyMapRetry = 0
local function InternalUpdateLocation()
	-- Acquire the new map ID.
	local mapID = CalculateCurrentMapID() or 0
	if mapID == 0 then
		-- some places really have no mapID, so don't loop infinitely and retain prior values
		if EmptyMapRetry > 10 and not app.RealMapID then
			if CurrentMapID == 0 then return end
			CurrentMapID = 0
			EmptyMapRetry = 0
			app.CurrentMapID = 0
			app.CurrentMapInfo = nil
			app.HandleEvent("OnCurrentMapIDChanged");
			return
		end
		EmptyMapRetry = EmptyMapRetry + 1
		Callback(InternalUpdateLocation)
		return
	end
	EmptyMapRetry = 0
	if CurrentMapID ~= mapID then
		CurrentMapID = mapID;
		app.CurrentMapID = mapID;
		app.CurrentMapInfo = C_Map_GetMapInfo(mapID);
		app.HandleEvent("OnCurrentMapIDChanged");
	end
end
local function UpdateLocation()
	-- Some of these location events trigger tons of times all at once
	Callback(InternalUpdateLocation)
end
app.AddEventHandler("OnReady", UpdateLocation);
app.AddEventRegistration("NEW_WMO_CHUNK", UpdateLocation);
app.AddEventRegistration("WAYPOINT_UPDATE", UpdateLocation);
app.AddEventRegistration("SCENARIO_UPDATE", UpdateLocation);
app.AddEventRegistration("ZONE_CHANGED", UpdateLocation);
app.AddEventRegistration("ZONE_CHANGED_INDOORS", UpdateLocation);
app.AddEventRegistration("ZONE_CHANGED_NEW_AREA", UpdateLocation);
app.AddEventRegistration("PLAYER_INTERACTION_MANAGER_FRAME_HIDE", UpdateLocation);

-- Maps Class
app.CreateMap = app.CreateClass("Map", "mapID", {
	name = function(t)
		return GetMapName(t.mapID);
	end,
	icon = function(t)
		return app.asset("Category_Zones");
	end,
	back = function(t)
		if t.isCurrentMap then
			return 1;
		end
	end,
	artID = function(t)
		return C_Map_GetMapArtID(t.mapID);
	end,
	lvl = function(t)
		return C_Map_GetMapLevels(t.mapID);
	end,
	playerCoord = function(t)
		local mapID = t.mapID
		if mapID < 0 then mapID = app.RealMapID end
		local position = mapID and C_Map_GetPlayerMapPosition(mapID, "player")
		if position then
			local x,y = position:GetXY()
			return { app.round(x * 100, 1), app.round(y * 100, 1), mapID };
		end
	end,
	isCurrentMap = function(t)
		if CurrentMapID == t.mapID then
			return true;
		end
		local maps = t.maps;
		if maps and contains(maps, CurrentMapID) then
			return true;
		end
	end,
	ignoreSourceLookup = app.ReturnTrue,
	isMinilistHeader = function(t)
		local mapinfo = C_Map_GetMapInfo(t.mapID)
		local mapType = mapinfo and mapinfo.mapType or 0
		local isHeader = mapType > 2
		t.isMinilistHeader = isHeader
		return isHeader
	end,
	SortType = function(t) return "MapClassSortType" end,
},
"WithHeader", {
	name = function(t)
		return L.HEADER_NAMES[t.headerID] or GetMapName(t.mapID);
	end,
	icon = function(t)
		return L.HEADER_ICONS[t.headerID] or app.asset("Category_Zones");
	end,
	lore = function(t)
		return L.HEADER_LORE[t.headerID];
	end,
	description = function(t)
		return L.HEADER_DESCRIPTIONS[t.headerID];
	end,
	ShouldShowEventSchedule = app.ReturnTrue,
}, (function(t)
	local creatureID = t.npcID
	if creatureID and creatureID < 0 then
		t.headerID = creatureID;
		t.npcID = nil;
		return true;
	elseif t.headerID then
		return true;
	end
end));

-- Exploration Class
local ExplorationAreaPositionDB = app.ExplorationAreaPositionDB or {};
local KEY, CACHE, CLASSNAME = "explorationID", "Exploration", "Exploration"
app.CreateExploration = app.CreateClass(CLASSNAME, KEY, {
	CACHE = function() return CACHE end,
	name = function(t)
		return C_Map_GetAreaInfo(t[KEY]) or UNKNOWN;
	end,
	description = function(t)
		if t.coords then
			if not TomTom then
				return "You can use Alt+Right Click to plot the coordinates with TomTom installed. If this refuses to be marked collected for you in ATT, try reloading your UI or relogging.";
			else
				return "You can use Alt+Right Click to plot the coordinates. If this refuses to be marked collected for you in ATT, try reloading your UI or relogging.";
			end
		else
			return "This exploration node is missing coordinates in our database. It may be unavailable.";
		end
	end,
	artID = function(t)
		return t.parent and (t.parent.artID or (t.parent.parent and t.parent.parent.artID));
	end,
	icon = function(t)
		return app.asset("Category_Exploration");
	end,
	mapID = function(t)
		return t.parent and (t.parent.mapID or (t.parent.parent and t.parent.parent.mapID));
	end,
	collectible = app.ReturnTrue,
	collected = function(t)
		return app.TypicalCharacterCollected(CACHE, t[KEY])
	end,
	saved = function(t)
		return app.IsCached(CACHE, t[KEY])
	end,
	coords = function(t)
		return ExplorationAreaPositionDB[t[KEY]];
	end,
	OnTooltip = app.EmptyFunction,
});
local function CacheAndUpdateExploration(areas)
	local changes = {};
	if app.SetBatchCachedAndTrackChanges(CACHE, areas, changes, 1) then
		app.UpdateRawIDs(KEY, changes);
	end
end
local function CheckHitsForMap(grid, mapID, saved)
	for _,pos in ipairs(grid) do
		local explored = C_MapExplorationInfo_GetExploredAreaIDsAtPosition(mapID, pos);
		if explored then
			for _,areaID in ipairs(explored) do
				saved[areaID] = true
			end
		end
	end
end
local function CheckHitsForExploration(exploration, saved)
	if exploration and exploration.coords and not saved[exploration.explorationID] then
		-- convert the coords into a grid for our common method
		for mapID,coordsForMap in pairs(exploration.coords) do
			-- Find all points on the grid that have explored an area and make note of them.
			local grid = {}
			for _,coord in ipairs(coordsForMap) do
				grid[#grid + 1] = CreateVector2D(coord[1] / 100, coord[2] / 100)
			end
			pcall(CheckHitsForMap, grid, mapID, saved);
		end
		-- app.print("Checking ExplorationID " .. explorationID .. "...");
		return true;
	end
end
local function CacheExplorationForAllMaps()
	app.print("Robust Map Exploration Started...")
	local grid, Granularity = {}, 200;
	for i=0,Granularity,1 do
		for j=0,Granularity,1 do
			tinsert(grid, CreateVector2D(i / Granularity, j / Granularity));
		end
	end
	local saved = {}
	for mapID,_ in pairs(app.GetFieldContainer("mapID")) do
		if C_Map_GetMapArtID(mapID) then
			app.print("Checking Map " .. mapID .. "...");
			coroutine.yield()
			-- Find all points on the grid that have explored an area and make note of them.
			pcall(CheckHitsForMap, grid, mapID, saved);
		end
	end
	CacheAndUpdateExploration(saved)
	app.print("Robust Map Exploration Cached")
end
local function CacheExplorationForAllKnownExploration()
	app.print("Known Map Exploration Started...")
	local saved = {}
	for explorationID,explorations in pairs(app.GetFieldContainer("explorationID")) do
		if CheckHitsForExploration(explorations[1], saved) then
			coroutine.yield();
		end
	end
	CacheAndUpdateExploration(saved)
	app.print("Known Map Exploration Cached")
end
-- add a parameter for a robust scan which checks all known Map data coords by scanning every map
-- without parameter do simple scan by cached ExplorationID coords
-- Allows a user to use /att collect-exploration [robust]
-- to force a full scan of all known ATT exploration or maps to cache visited exploration data
app.ChatCommands.Add("collect-exploration", function(args)
	app:StartATTCoroutine("FullMapExploration", args[2] and CacheExplorationForAllMaps or CacheExplorationForAllKnownExploration)
	return true
end, {
	"Usage : /att collect-exploration [robust]",
	"This allows a user to fully-scan the entire known set of Zones to harvest current Exploration data.",
	"NOTE: This operation (when providing the 'robust' parameter) should only be needed once per Character as Exploration is otherwise updated when new areas are explored.",
})
local function CollectibleForExploration(t)
	return t.coords;
end
local function CheckExplorationForNode(exploration)
	local saved = {}
	if CheckHitsForExploration(exploration, saved) then
		CacheAndUpdateExploration(saved)
	end
	return saved;
end
local function OnTooltipForExploration(exploration, tooltipInfo)
	if exploration.collected then return; end
	app.GetCachedData(exploration.hash, CheckExplorationForNode, exploration);
end
local function AssignExplorationMethods()
	if app.Settings.Collectibles[CLASSNAME] then
		app.SwapClassDefinitionMethod(CLASSNAME,"collectible",CollectibleForExploration)
		app.SwapClassDefinitionMethod(CLASSNAME,"OnTooltip",function() return OnTooltipForExploration end)
	else
		app.SwapClassDefinitionMethod(CLASSNAME,"collectible",app.ReturnFalse)
		app.SwapClassDefinitionMethod(CLASSNAME,"OnTooltip",app.EmptyFunction)
	end
end
app.AddEventHandler("OnSettingsNeedsRefresh", AssignExplorationMethods);
app.AddEventHandler("OnStartup", AssignExplorationMethods);
app.AddEventHandler("OnSavedVariablesAvailable", function(currentCharacter, accountWideData)
	if not currentCharacter[CACHE] then currentCharacter[CACHE] = {} end
	if not accountWideData[CACHE] then accountWideData[CACHE] = {} end
end)

-- Reporting
local AreaIDNameMapper = setmetatable({}, {__index = function(t,key)
	local id = #t + 1
	local keyid = tonumber(key)
	local name
	while id < 25000 do
		-- ref. https://wago.tools/db2/AreaTable
		name = C_Map_GetAreaInfo(id)
		if name then
			t[name] = id
		end
		t[id] = name or UNKNOWN
		if key == name then
			-- app.PrintDebug("Found AreaID",id,"for",key)
			return id
		end
		if keyid == id then
			-- app.PrintDebug("Found Name",name,"for",id)
			return name or UNKNOWN
		end
		id = id + 1
	end
end})
-- Reporting (backwards ID search)
local AreaIDNameMapperBackwards = setmetatable({}, {__index = function(t,key)
	local maxID = 25000
	local id = maxID - 1
	local keyid = tonumber(key)
	local name
	while id > 0 do -- scan backwards
		-- ref. https://wago.tools/db2/AreaTable
		name = C_Map_GetAreaInfo(id)
		if name then
			t[name] = id
		end
		t[id] = name or UNKNOWN
		if key == name then
			-- app.PrintDebug("Found AreaID",id,"for",key)
			return id
		end
		if keyid == id then
			-- app.PrintDebug("Found Name",name,"for",id)
			return name or UNKNOWN
		end
		id = id - 1
	end
end})
local MapExplorationInfoEventIDs = {
	[372] = true,
	[396] = true,
	[408] = true,
}
local ExplorationReportLines, ReportedAreas = {}, {};
local function PrintDiscordInformationForExploration(o, type)
	-- Temporarily disabled reports for users until we have most areas sorted.
	-- We can't rely on the ID guessing based on the area name when we miss so many still.
	if true then return end

	if not app.Contributor then return end
	if not type then return end
	local areaID = o.explorationID
	if not areaID or ReportedAreas[areaID] then return end
	ReportedAreas[areaID] = o

	local text = o.text or "???"
	local mapID = o.mapID
	if mapID then
		text = text .. " (" .. GetMapName(mapID) .. ")"
	end

	local position = mapID and C_Map_GetPlayerMapPosition(mapID, "player")
	local x, y
	if position then
		x, y = position:GetXY()
		x = math_floor(x * 1000) / 10
		y = math_floor(y * 1000) / 10
	end

	local inInstance = IsInInstance()
	if not inInstance and (not x or not y) then
		app.print("Area", areaID, "has no valid coords on mapID", mapID)
	end

	local luaFormat

	if type == "subzone" then
		if position then
			luaFormat = "visit_exploration(%d,{coord={%.1f,%.1f,%d}}),\t-- %s"
			tinsert(ExplorationReportLines, luaFormat:format(areaID, x or 0, y or 0, mapID, text))
		else
			luaFormat = "visit_exploration(%d),\t-- %s"
			tinsert(ExplorationReportLines, luaFormat:format(areaID, text))
		end
	elseif type == "zone" then
		if position then
			luaFormat = "map_exploration(%d,{coord={%.1f,%.1f,%d}}),\t-- %s"
			tinsert(ExplorationReportLines, luaFormat:format(areaID, x or 0, y or 0, mapID, text))
		else
			luaFormat = "map_exploration(%d),\t-- %s"
			tinsert(ExplorationReportLines, luaFormat:format(areaID, text))
		end
	end

	local popupID = "exploration-report-" .. areaID
	app:SetupReportDialog(popupID, "Exploration Reports", ExplorationReportLines)
	app.print("Found Unmapped Area (" .. type .. "):", app:Linkify(text, app.Colors.ChatLinkError, "dialog:" .. popupID))
	app.Audio:PlayReportSound()
end
local function GetExplorationByZoneOrSubzone(mapID)
	local zonesToCheck = {}
	local subzone = GetSubZoneText()
	if subzone and subzone ~= "" then
		zonesToCheck[subzone] = "subzone";
	end
	local zone = GetRealZoneText()
	if zone and zone ~= "" then
		zonesToCheck[zone] = "zone";
	end

	local results = {}
	for name, type in pairs(zonesToCheck) do
		local foundExploration

		-- Look in existing ATT data
		local mapObject = app.SearchForObject("mapID", mapID, "key")
		if mapObject and mapObject.g then
			for _,o in ipairs(mapObject.g) do
				if o.headerID == app.HeaderConstants.EXPLORATION and o.g then
					for _,e in ipairs(o.g) do
						if e.name == name and e.__type == "Exploration" and e.coords then
							foundExploration = e
							break
						end
					end
				end
				if foundExploration then break end
			end
		end

		-- If not found, try via AreaIDNameMapper or AreaIDNameMapperBackwards
		if not foundExploration and app.Contributor then
			local expectedAreaID = AreaIDNameMapper[name]
			-- It's easier to find correct explorationID for newer expansions when going backwards
			--local expectedAreaID = AreaIDNameMapperBackwards[name]
			if expectedAreaID then
				-- Don't report an area which is actually mapped in another zone already
				local mappedExploration = app.SearchForObject("explorationID", expectedAreaID)
				-- app.PrintDebug("mappedExploration",mappedExploration,mappedExploration and mappedExploration.__type)
				-- Not in ATT at all
				if not mappedExploration then
					foundExploration = app.CreateExploration(expectedAreaID, { mapID = mapID, name = name })
					PrintDiscordInformationForExploration(foundExploration, type)
				-- In ATT as NYI or Unsorted
				elseif mappedExploration._missing or app.GetRelativeValue(mappedExploration, "_nyi") then
					-- Inject some data into the exploration object so we can report about it properly
					mappedExploration.mapID = mapID
					mappedExploration.name = name
					PrintDiscordInformationForExploration(mappedExploration, type)
					foundExploration = mappedExploration
				else
					-- in ATT without coords, likely means it can't be detected in API since it would be populated
					if C_Map_GetPlayerMapPosition(mapID, "player") and not mappedExploration.coords then
						PrintDiscordInformationForExploration(mappedExploration, type)
					end
					foundExploration = mappedExploration
				end
			else
				app.PrintDebug("No AreaID found for", type, name)
			end
		end

		-- Save if we found something
		if foundExploration then results[#results + 1] = foundExploration end
	end

	-- Return all explorations (could be 0, 1, or 2)
	return results
end
local function CheckIfExplorationIsMissing(mapID)
	-- do a manual check by way of the zone or sub-zone name (since this is what correlates to the exploration name players see in ATT)
	-- we will provide a manual collection by way of exact player position having a specific zone or subzone name when performing a check
	local explorationForZoneOrSubzone = GetExplorationByZoneOrSubzone(mapID)
	if explorationForZoneOrSubzone then
		for i,zone in ipairs(explorationForZoneOrSubzone) do
			-- app.PrintDebug("ZoneOrSubzoneExplorationFind",zone,app:SearchLink(zone))
			local areaID = zone.explorationID
			local characterExploration = app.CurrentCharacter.Exploration
			-- don't know how areaID could be nil here...
			if areaID and not characterExploration[areaID] then
				-- app.PrintDebug("Manual cached Exploration by Zone or Subzone name")
				-- we won't use regular caching since we're manually checking instead of the expected API utilization
				-- maybe eventually blizzard will fix the API
				characterExploration[areaID] = 2
				CacheAndUpdateExploration({[areaID]=true})
			end
		end
	end
end
local function InternalCheckExplorationForPlayerPosition()
	local mapID = C_Map_GetBestMapForUnit("player");
	if not mapID then return; end
	CheckIfExplorationIsMissing(mapID)
	local pos = C_Map_GetPlayerMapPosition(mapID, "player");
	if not pos then return; end
	local areaIDs = C_MapExplorationInfo_GetExploredAreaIDsAtPosition(mapID, pos);
	if not areaIDs then return end;

	-- test manually:
	-- /dump C_MapExplorationInfo.GetExploredAreaIDsAtPosition(ATTC.RealMapID, C_Map.GetPlayerMapPosition(ATTC.RealMapID, "player"))

	local characterExploration = app.CurrentCharacter.Exploration
	local newAreas = {};
	local saved = {}
	for _,areaID in ipairs(areaIDs) do
		-- app.PrintDebug("CheckPlayerExploration",mapID,pos.x,pos.y,app:SearchLink(app.SearchForObject("explorationID",areaID,"field")))
		if not characterExploration[areaID] then
			saved[areaID] = true
			tinsert(newAreas, areaID);
		end
		if not ReportedAreas[areaID] then
			if #app.SearchForField("explorationID", areaID) < 1 then
				PrintDiscordInformationForExploration(app.CreateExploration(areaID, { mapID = mapID}));
			end
		end
	end
	if #newAreas > 0 then
		CacheAndUpdateExploration(saved)
	end
end
local function CheckExplorationForPlayerPosition()
	Callback(InternalCheckExplorationForPlayerPosition);
end
app.AddEventHandler("OnReportReset", function() wipe(ReportedAreas) end)
app.AddEventHandler("OnRefreshCollections", CheckExplorationForPlayerPosition)
app.AddEventRegistration("MAP_EXPLORATION_UPDATED", CheckExplorationForPlayerPosition)
app.AddEventRegistration("UI_INFO_MESSAGE", function(messageID, ...)
	if MapExplorationInfoEventIDs[messageID] then
		-- app.PrintDebug("UI_INFO_MESSAGE", messageID, ...)
		CheckExplorationForPlayerPosition()
	end
end)
app.ChatCommands.Add("realtime-exploration-check", function(args)
	app.AddEventRegistration("FOG_OF_WAR_UPDATED", CheckExplorationForPlayerPosition)
	app.print("Enabled: realtime-exploration-check")
	return true
end, {
	"Usage : /att realtime-exploration-check",
	"This enables ATT to perform real-time Exploration checks when the Player visits new sub-zones of maps. This cannot be turned off except by reloading the UI.",
	"NOTE: This is not intended to be used except by Contribs in order to do fine-tuned testing of Exploration data",
})

-- Harvesting
local ExplorationDB, FilterInGame
local function GetAvgCoords(coords)
	local newCoords = {};
	for mapID,coordsForMap in pairs(coords) do
		local x_total, y_total = 0,0
		local total = #coordsForMap;
		for i=1,total do
			x_total = x_total + coordsForMap[i][1]
			y_total = y_total + coordsForMap[i][2]
		end
		newCoords[mapID] = {
			math_floor(100 * x_total / total) / 100,
			math_floor(100 * y_total / total) / 100
		};
	end
	return newCoords;
end
local function GetBestCoords(coords)
	local bestCoords = {};
	local checkCoord, checkDistance
	for mapID,avg_coord in pairs(GetAvgCoords(coords)) do
		local coordsForMap = coords[mapID];
		if coordsForMap and #coordsForMap > 0 then
			-- app.PrintDebug("Find Best coord to",avg_coord[1],avg_coord[2],"from",#coordsForMap)
			-- grab a real coord which is closest to the avg coord (in case the avg coord is somehow outside the set of valid coords)
			local bestCoord = coordsForMap[1]
			local minDistance = distance(bestCoord[1], bestCoord[2], avg_coord[1], avg_coord[2])
			if minDistance >= 1 then
				for i=2,#coordsForMap,1 do
					checkCoord = coordsForMap[i]
					checkDistance = distance(checkCoord[1], checkCoord[2], avg_coord[1], avg_coord[2])
					if checkDistance < minDistance then
						minDistance = checkDistance
						bestCoord = checkCoord
						-- once we've narrowed down within a coord from the avg_coord, just return
						if checkDistance < 1 then
							-- app.PrintDebug("Quick Best coord:",bestCoord[1],bestCoord[2],bestCoord[3])
							break;
						end
					end
				end
			end
			-- app.PrintDebug("Final Best coord:",bestCoord[1],bestCoord[2],bestCoord[3])
			bestCoords[mapID] = {bestCoord};
		end
	end
	return bestCoords;
end
local function PrintReportedAreaSummary()
	local reportedAreas = {};
	for areaID,o in pairs(ReportedAreas) do
		tinsert(reportedAreas, o);
	end
	if #reportedAreas > 0 then
		-- Create an information object.
		local info = {
			"### Found Area Summary",
			"```lua",
		};

		local areasByMapID = setmetatable({}, app.MetaTable.AutoTable);
		for i,o in ipairs(reportedAreas) do
			tinsert(areasByMapID[o.mapID], o);
		end
		for mapID,areas in pairs(areasByMapID) do
			app.Sort(areas, app.SortDefaults.text);
			tinsert(info, "-- " .. GetMapName(mapID) .. " (" .. mapID .. ")");
			for i,o in ipairs(areas) do
				tinsert(info, "exploration(" .. o.explorationID .. "),\t-- " .. o.text);
			end
			tinsert(info, "");
		end

		tinsert(info, "ver: "..app.Version);
		tinsert(info, "build: "..app.GameBuildVersion);
		tinsert(info, "```");	-- discord fancy box end

		local popupID, text = "found-area-summary", "Summary";
		app:SetupReportDialog(popupID, text, info);
		app.print("Found Areas:", app:Linkify(text, app.Colors.ChatLinkError, "dialog:" .. popupID));
	end
end
local function OnClickForExplorationHeader(row, button)
	if button == "RightButton" and IsControlKeyDown() then
		local info = {};
		for i,exploration in ipairs(row.ref.g) do
			tinsert(info, "exploration(" .. exploration.explorationID .. "),\t-- " .. exploration.text);
		end
		app:ShowPopupDialogWithMultiLineEditBox(app.TableConcat(info, nil, nil, "\n"), nil, "Exploration Data");
		return true;
	end
end
local function GenerateHitsForMap(grid, mapID)
	if mapID == 594 or mapID == 2091 then return nil, nil; end	-- Shattrath City messing up Talador, War of the Shifting Sands messing up Silithus
	local any, areaHits = false, setmetatable({}, app.MetaTable.AutoTableOfTables);
	local explored, subMapID, mapInfo
	for _,pos in ipairs(grid) do
		explored = C_MapExplorationInfo_GetExploredAreaIDsAtPosition(mapID, pos);
		if explored and #explored > 0 then
			mapInfo = C_Map_GetMapInfoAtPosition(mapID, pos.x, pos.y)
			subMapID = mapInfo and mapInfo.mapID or mapID;
			local coord = {pos.x * 100, pos.y * 100};
			for _,areaID in ipairs(explored) do
				tinsert(areaHits[areaID][subMapID], coord)
			end
			any = true;
		end
	end
	if not any then
		app.print("No Exploration Areas Found in",app:SearchLink(app.SearchForObject("mapID",mapID,"key")))
	end
	return any, areaHits
end
local function IncorporateHitsIntoDBs(hits, mapID, reportedAreasByID)
	-- For each of these fresh hits, add it to our raw positional DB.
	local foundArea
	local count = 0

	for areaID,coords in pairs(hits) do
		foundArea = app.SearchForObject("explorationID", areaID)
		if not foundArea or not FilterInGame(foundArea) then
			reportedAreasByID[areaID] = app.CreateExploration(areaID, { mapID = mapID});
		end
		tinsert(ExplorationDB[mapID], areaID)

		-- Record the best coordinates
		local any = false;
		for m,coordsForMap in pairs(coords) do
			if #coordsForMap > 0 then
				any = true;
				break;
			end
		end
		if any then
			ExplorationAreaPositionDB[areaID] = GetBestCoords(coords)
			count = count + 1
		else
			app.print("No coords found!",areaID)
		end
	end
	app.print("Captured",count,"Areas")
end
local function HarvestExploration()
	-- setup our DB captures
	local harvest = app.LocalizeGlobal("AllTheThingsHarvestItems", true)
	harvest.ExplorationAreaPositionDB = ExplorationAreaPositionDB
	-- ExplorationDB is only used internally while harvesting to populate Exploration headers
	ExplorationDB =  setmetatable({}, app.MetaTable.AutoTable);

	FilterInGame = app.Modules.Filter.Filters.InGame

	app.print("Harvesting Exploration...");
	wipe(ReportedAreas)
	local grid, Granularity = {}, 200;
	for i=0,Granularity,1 do
		for j=0,Granularity,1 do
			tinsert(grid, CreateVector2D(i / Granularity, j / Granularity));
		end
	end
	local saved = {}
	for mapID,objects in pairs(app.GetFieldContainer("mapID")) do
		-- only check exploration on Zone maps where we have a raw map listed in ATT
		local mapInfo = C_Map_GetMapInfo(mapID)
		if mapInfo and (mapInfo.mapType == 3 or mapInfo.mapType == 6) and C_Map_GetMapArtID(mapID) and app.SearchForObject("mapID",mapID,"key") then
			app.print("Harvesting Map " .. mapID .. "...");
			-- Find all points on the grid that have explored an area and make note of them.
			local ok, any, hits = pcall(GenerateHitsForMap, grid, mapID);
			if ok and hits then
				IncorporateHitsIntoDBs(hits, mapID, ReportedAreas)
				for areaID,coords in pairs(hits) do
					saved[areaID] = true
				end

				-- If any were found, update the content of the exploration headers.
				for i,object in ipairs(objects) do
					if object.key == "mapID" and (object.mapID == mapID or (object.maps and contains(object.maps, mapID))) then
						-- Cache or Create the Exploration Header
						local explorationHeader = nil;
						if object.g then
							for j,o in ipairs(object.g) do
								if o.headerID == app.HeaderConstants.EXPLORATION then
									explorationHeader = o;
									break;
								end
							end
						else
							object.g = {};
						end
						local byExplorationID;
						if explorationHeader then
							byExplorationID = explorationHeader.ByExplorationID;
							if not byExplorationID then
								byExplorationID = {};
								explorationHeader.ByExplorationID = byExplorationID;
							end
							if explorationHeader.g then
								for j,o in ipairs(explorationHeader.g) do
									local areaID = o.explorationID;
									if areaID then byExplorationID[areaID] = o; end
								end
							else
								explorationHeader.g = {};
							end
						else
							byExplorationID = {};
							explorationHeader = app.CreateCustomHeader(app.HeaderConstants.EXPLORATION);
							explorationHeader.ByExplorationID = byExplorationID;
							explorationHeader.g = {};
							explorationHeader.u = object.u;
							explorationHeader.parent = object;
							tinsert(object.g, 1, explorationHeader);
						end
						explorationHeader.OnClick = OnClickForExplorationHeader;
						explorationHeader.SortType = "text";

						-- Make sure the exploration header has all the objects
						for _,areaID in ipairs(ExplorationDB[mapID]) do
							if not byExplorationID[areaID] then
								local o = app.CreateExploration(areaID);
								o.mapID = mapID;
								o.parent = explorationHeader;
								tinsert(explorationHeader.g, o);
								byExplorationID[areaID] = o;
								local searchResults = app.SearchForField("explorationID", areaID);
								if #searchResults < 1 or ReportedAreas[areaID] then
									PrintDiscordInformationForExploration(o);
								end
								tinsert(searchResults, o);
							end
						end
					end
				end
			end
			repeat
				coroutine.yield();
			until(not InCombatLockdown());
		end
	end

	-- Clean up areaID's with no coords
	local emptyAreaIDs = {}
	for areaID,coords in pairs(ExplorationAreaPositionDB) do
		local any = false;
		for mapID,coordsForMap in pairs(coords) do
			if #coordsForMap > 0 then
				any = true;
				break;
			end
		end
		if not any then
			emptyAreaIDs[areaID] = true
		end
	end
	for areaID,_ in pairs(emptyAreaIDs) do
		ExplorationAreaPositionDB[areaID] = nil
	end
	CacheAndUpdateExploration(saved)
	app.print("Exploration Harvest complete. You can now Ctrl+Right Click on an Exploration header to copy its content.");
	PrintReportedAreaSummary()
	collectgarbage();
end
app.HarvestExploration = function()
	app:StartATTCoroutine("Harvest Exploration", HarvestExploration);
	return true;
end
-- Allows a user to use /att harvest-exploration
-- to force a full scan of all known ATT maps to harvest exploration data
app.ChatCommands.Add("harvest-exploration", app.HarvestExploration, {
	"Usage : /att harvest-exploration",
	"This allows fully-scanning the entire known set of Zones to harvest current Exploration data based on what the current character has Explored, and capture all the possible coordinates which 'should' unlock the specified Exploration Areas.",
	"NOTE: This operation is only expected to be used by Development in order to update Exploration in the addon!",
})

app.ChatCommands.Add("harvest-map", function(args)
	local mapID = tonumber(args[2])
	if not mapID then return end
	local granularity = tonumber(args[3] or 200)
	local simplify = args[4]
	-- setup our DB captures
	local harvest = app.LocalizeGlobal("AllTheThingsHarvestItems", true)
	harvest.ExplorationAreaPositionDB = ExplorationAreaPositionDB
	-- ExplorationDB is only used internally while harvesting to populate Exploration headers
	ExplorationDB =  setmetatable({}, app.MetaTable.AutoTable);
	FilterInGame = app.Modules.Filter.Filters.InGame
	wipe(ReportedAreas)
	app.print("Harvesting Map",mapID,"@",granularity,simplify and "[Simplified]" or "[Full]");
	local grid = {}
	for i=0,granularity,1 do
		for j=0,granularity,1 do
			tinsert(grid, CreateVector2D(i / granularity, j / granularity));
		end
	end

	local ok, any, hits = pcall(GenerateHitsForMap, grid, mapID)
	if not ok then app.print("Failed to find any valid areaIDs on map",mapID) return end
	IncorporateHitsIntoDBs(hits, mapID, ReportedAreas)
	local i = 6033345
	local tempGroup = {visible=true,text=app.GetMapName(mapID)}
	for areaID,coords in pairs(hits) do
		app.NestObject(tempGroup, app.CreateExploration(areaID, {coords=(simplify and GetBestCoords(coords) or coords),icon=i}))
		i = i + 1
		if i > 6033354 then i = 6033345 end
	end
	-- app.AddTomTomWaypoint(tempGroup)
	-- app.PrintTable(tempGroup)
	-- app.PrintTable(hits)
	app:CreateMiniListForGroup(tempGroup, true)
	PrintReportedAreaSummary()
end, {
	"Usage : /att harvest-map mapID [granularity] [simplify]",
})



-- Instance Class
local instanceFields = {
	name = function(t)
		return GetMapName(t.mapID);
	end,
	icon = function(t)
		return app.asset("Category_Zones");
	end,
	back = function(t)
		if t.isCurrentMap then
			return 1;
		end
	end,
	mapID = function(t)
		return t.maps and t.maps[1];
	end,
	lvl = function(t)
		local mapID = t.mapID;
		return mapID and C_Map_GetMapLevels(mapID);
	end,
	locks = function(t)
		local savedInstanceID = t.savedInstanceID;
		if savedInstanceID then
			local lockouts = app.CurrentCharacter.Lockouts;
			local locks = lockouts[savedInstanceID];
			if locks then
				t.locks = locks;
				return locks;
			end
		end
	end,
	saved = function(t)
		return t.locks;
	end,
	isCurrentMap = function(t)
		if CurrentMapID == t.mapID then
			return true;
		end
		local maps = t.maps;
		if maps and contains(maps, CurrentMapID) then
			return true;
		end
	end,
	isLockoutShared = app.ReturnFalse,
	ignoreSourceLookup = app.ReturnTrue,
};
local EJ_GetInstanceInfo = EJ_GetInstanceInfo;
if EJ_GetInstanceInfo and app.GameBuildVersion >= 50000 then
	local cache = app.CreateCache("instanceID");
	local function CacheInfo(t, field)
		local _t, id = cache.GetCached(t);
		local name, lore, _, _, _, icon, _, link = EJ_GetInstanceInfo(id);
		if name then _t.name = name; end
		if lore then _t.lore = lore; end
		if icon then _t.icon = icon; end
		if link then _t.link = link; end
		if field then return _t[field]; end
	end
	local oldIconField = instanceFields.icon;
	local oldNameField = instanceFields.name;
	instanceFields.icon = function(t) return cache.GetCachedField(t, "icon", CacheInfo) or oldIconField(t); end;
	instanceFields.name = function(t) return cache.GetCachedField(t, "name", CacheInfo) or oldNameField(t); end;
	instanceFields.lore = function(t) return cache.GetCachedField(t, "lore", CacheInfo); end;
	instanceFields.silentLink = function(t) return cache.GetCachedField(t, "link", CacheInfo); end;
end
app.CreateInstance = app.CreateClass("Instance", "instanceID", instanceFields,
"WithHeader", {
	["name"] = function(t)
		return L.HEADER_NAMES[t.headerID] or instanceFields.name(t);
	end,
	["icon"] = function(t)
		return L.HEADER_ICONS[t.headerID] or app.asset("Category_Zones");
	end,
	["lore"] = function(t)
		return L.HEADER_LORE[t.headerID];
	end,
	["description"] = function(t)
		return L.HEADER_DESCRIPTIONS[t.headerID];
	end,
}, (function(t)
	if t.headerID then
		return true;
	else
		local creatureID = t.npcID
		if creatureID and creatureID < 0 then
			t.headerID = creatureID;
			t.npcID = nil;
			return true;
		end
	end
end));

-- Instance Event Handling
local GetNumSavedInstances, GetServerTime, GetSavedInstanceInfo, GetSavedInstanceEncounterInfo, RequestRaidInfo
	= GetNumSavedInstances, GetServerTime, GetSavedInstanceInfo, GetSavedInstanceEncounterInfo, RequestRaidInfo;
local AfterCombatCallback = app.CallbackHandlers.AfterCombatCallback;
local function RefreshSavesCallback()
	-- While the player is still logging in, wait.
	if not app.GUID then
		AfterCombatCallback(RefreshSavesCallback);
		return;
	end

	-- Cache the lockouts across your account.
	local serverTime = GetServerTime();

	-- Check to make sure that the old instance data has expired
	for guid,character in pairs(ATTCharacterData) do
		local locks = character.Lockouts;
		if locks then
			for instanceID,instance in pairs(locks) do
				local count = 0;
				for difficulty,lock in pairs(instance) do
					if type(lock) ~= "table" or type(lock.reset) ~= "number" or serverTime >= lock.reset then
						-- Clean this up.
						instance[difficulty] = nil;
					else
						count = count + 1;
					end
				end
				if count == 0 then
					-- Clean this up.
					locks[instanceID] = nil;
				end
			end
		end
	end

	-- Make sure there's info available to check save data
	local saves = GetNumSavedInstances();
	if not saves then
		-- While the player is still waiting for information, wait.
		AfterCombatCallback(RefreshSavesCallback);
		return;
	end

	-- Update Saved Instances
	local myLockouts = app.CurrentCharacter.Lockouts;
	for instanceIter=1,saves do
		local name, id, reset, difficulty, locked, _, _, isRaid, _, _, numEncounters, encounterProgress, extendDisabled, savedInstanceID = GetSavedInstanceInfo(instanceIter);
		if locked and savedInstanceID then
			-- Cache the locks for this instance
			difficulty = difficulty or 7;
			reset = serverTime + reset;
			local locks = myLockouts[savedInstanceID];
			if not locks then
				locks = {};
				myLockouts[savedInstanceID] = locks;
			end

			-- Create the lock for this difficulty
			local lock = locks[difficulty];
			if not lock then
				lock = { ["id"] = id, ["reset"] = reset, ["encounters"] = {}};
				locks[difficulty] = lock;
			else
				lock.id = id;
				lock.reset = reset;
			end

			-- If this is LFR, then don't share.
			if difficulty == 7 or difficulty == 17 then
				if #lock.encounters == 0 then
					-- Check Encounter locks
					for encounterIter=1,numEncounters do
						local name, _, isKilled = GetSavedInstanceEncounterInfo(instanceIter, encounterIter);
						tinsert(lock.encounters, { ["name"] = name, ["isKilled"] = isKilled });
					end
				else
					-- Check Encounter locks
					for encounterIter=1,numEncounters do
						local name, _, isKilled = GetSavedInstanceEncounterInfo(instanceIter, encounterIter);
						if not lock.encounters[encounterIter] then
							tinsert(lock.encounters, { ["name"] = name, ["isKilled"] = isKilled });
						elseif isKilled then
							lock.encounters[encounterIter].isKilled = true;
						end
					end
				end
			else
				-- Create the pseudo "shared" lock
				local shared = locks.shared;
				if not shared then
					shared = {};
					shared.id = id;
					shared.reset = reset;
					shared.encounters = {};
					locks.shared = shared;

					-- Check Encounter locks
					for encounterIter=1,numEncounters do
						local name, _, isKilled = GetSavedInstanceEncounterInfo(instanceIter, encounterIter);
						tinsert(lock.encounters, { ["name"] = name, ["isKilled"] = isKilled });

						-- Shared Encounter is always assigned if this is the first lock seen for this instance
						tinsert(shared.encounters, { ["name"] = name, ["isKilled"] = isKilled });
					end
				else
					-- Check Encounter locks
					for encounterIter=1,numEncounters do
						local name, _, isKilled = GetSavedInstanceEncounterInfo(instanceIter, encounterIter);
						if not lock.encounters[encounterIter] then
							tinsert(lock.encounters, { ["name"] = name, ["isKilled"] = isKilled });
						elseif isKilled then
							lock.encounters[encounterIter].isKilled = true;
						end
						if not shared.encounters[encounterIter] then
							tinsert(shared.encounters, { ["name"] = name, ["isKilled"] = isKilled });
						elseif isKilled then
							shared.encounters[encounterIter].isKilled = true;
						end
					end
				end
			end
		end
	end

	-- Mark that we're done now.
	app.HandleEvent("OnSavesUpdated");
end
app.AddEventRegistration("LOOT_CLOSED", function()
	-- Once the loot window closes after killing a boss, THEN trigger the update.
	app:UnregisterEvent("LOOT_CLOSED");
	app:RegisterEvent("UPDATE_INSTANCE_INFO");
	RequestRaidInfo();
end, true)
local function Event_UPDATE_INSTANCE_INFO()
	app:UnregisterEvent("UPDATE_INSTANCE_INFO");
	AfterCombatCallback(RefreshSavesCallback);
end
app.AddEventRegistration("UPDATE_INSTANCE_INFO", Event_UPDATE_INSTANCE_INFO, true)
app.AddEventHandler("OnRefreshCollectionsDone", Event_UPDATE_INSTANCE_INFO);
app.AddEventRegistration("BOSS_KILL", function(id, name, ...)
	-- This is so that when you kill a boss, you can trigger
	-- an automatic update of your saved instance cache.
	-- (It does lag a little, but you can disable this if you want.)
	-- Waiting until the LOOT_CLOSED occurs will prevent the failed Auto Loot bug.
	-- print("BOSS_KILL", id, name, ...);
	app:RegisterEvent("LOOT_CLOSED");
end);
app.AddEventHandler("OnSavedVariablesAvailable", function(currentCharacter, accountWideData)
	if not currentCharacter.Lockouts then currentCharacter.Lockouts = {} end
end)
