local GAResourceFlowType = require(script.GAResourceFlowType)
local GAProgressionStatus = require(script.GAProgressionStatus)
local GAErrorSeverity = require(script.GAErrorSeverity)

local ga = {
	EGAResourceFlowType = GAResourceFlowType,
	EGAProgressionStatus = GAProgressionStatus,
	EGAErrorSeverity = GAErrorSeverity,
}

local types = require(script.Types)
local logger = require(script.Logger)
local threading = require(script.Threading)
local state = require(script.State)
local validation = require(script.Validation)
local store = require(script.Store)
local events = require(script.Events)
local utilities = require(script.Utilities)
local Players = game:GetService("Players")
local MKT = game:GetService("MarketplaceService")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local LocalizationService = game:GetService("LocalizationService")
local ScriptContext = game:GetService("ScriptContext")
local Postie = require(script.Postie)
local OnPlayerReadyEvent
local ProductCache = {}
local ONE_HOUR_IN_SECONDS = 3600
local MaxErrorsPerHour = 10
local ErrorDS = {}
local errorCountCache = {}
local errorCountCacheKeys = {}

local InitializationQueue = {}
local InitializationQueueByUserId = {}

type BusinessEventOptions = types.BusinessEventOptions
type ResourceEventOptions = types.ResourceEventOptions
type ProgressionEventOptions = types.ProgressionEventOptions
type DesignEventOptions = types.DesignEventOptions
type ErrorEventOptions = types.ErrorEventOptions
type CustomDimension = types.CustomDimension
type ProductInfo = types.ProductInfo
type ProcessReceiptInfo = types.ProcessReceiptInfo
type TeleportData = types.TeleportData
type RemoteConfigs = types.RemoteConfigs
type GameAnalyticsOptions = types.GameAnalyticsOptions

local function addToInitializationQueue(func, ...)
	if InitializationQueue ~= nil then
		table.insert(InitializationQueue, {
			Func = func,
			Args = { ... },
		})

		logger:i("Added event to initialization queue")
	else
		--This should never happen
		logger:w("Initialization queue already cleared.")
	end
end

local function addToInitializationQueueByUserId(userId, func, ...)
	if not ga:isPlayerReady(userId) then
		if InitializationQueueByUserId[userId] == nil then
			InitializationQueueByUserId[userId] = {}
		end

		table.insert(InitializationQueueByUserId[userId], {
			Func = func,
			Args = { ... },
		})

		logger:i("Added event to player initialization queue")
	else
		--This should never happen
		logger:w("Player initialization queue already cleared.")
	end
end

-- local functions
local function isSdkReady(options)
	local playerId = options["playerId"] or nil
	local needsInitialized = options["needsInitialized"] or true
	local shouldWarn = options["shouldWarn"] or false
	local message = options["message"] or ""

	-- Is SDK initialized
	if needsInitialized and not state.Initialized then
		if shouldWarn then
			logger:w(message .. " SDK is not initialized")
		end

		return false
	end

	-- Is SDK enabled
	if needsInitialized and playerId and not state:isEnabled(playerId) then
		if shouldWarn then
			logger:w(message .. " SDK is disabled")
		end

		return false
	end

	-- Is session started
	if needsInitialized and playerId and not state:sessionIsStarted(playerId) then
		if shouldWarn then
			logger:w(message .. " Session has not started yet")
		end

		return false
	end

	return true
end

function ga:configureAvailableCustomDimensions01(customDimensions: { string })
	if isSdkReady({ needsInitialized = true, shouldWarn = false }) then
		logger:w("Available custom dimensions must be set before SDK is initialized")
		return
	end

	state:setAvailableCustomDimensions01(customDimensions)
end

function ga:configureAvailableCustomDimensions02(customDimensions: { string })
	if isSdkReady({ needsInitialized = true, shouldWarn = false }) then
		logger:w("Available custom dimensions must be set before SDK is initialized")
		return
	end

	state:setAvailableCustomDimensions02(customDimensions)
end

function ga:configureAvailableCustomDimensions03(customDimensions: { string })
	if isSdkReady({ needsInitialized = true, shouldWarn = false }) then
		logger:w("Available custom dimensions must be set before SDK is initialized")
		return
	end

	state:setAvailableCustomDimensions03(customDimensions)
end

function ga:configureAvailableResourceCurrencies(resourceCurrencies: { string })
	if isSdkReady({ needsInitialized = true, shouldWarn = false }) then
		logger:w("Available resource currencies must be set before SDK is initialized")
		return
	end

	events:setAvailableResourceCurrencies(resourceCurrencies)
end

function ga:configureAvailableResourceItemTypes(resourceItemTypes: { string })
	if isSdkReady({ needsInitialized = true, shouldWarn = false }) then
		logger:w("Available resource item types must be set before SDK is initialized")
		return
	end

	events:setAvailableResourceItemTypes(resourceItemTypes)
end

function ga:configureBuild(build: string)
	if isSdkReady({ needsInitialized = true, shouldWarn = false }) then
		logger:w("Build version must be set before SDK is initialized.")
		return
	end

	events:setBuild(build)
end

function ga:configureAvailableGamepasses(availableGamepasses: { string })
	if isSdkReady({ needsInitialized = true, shouldWarn = false }) then
		logger:w("Available gamepasses must be set before SDK is initialized.")
		return
	end

	state:setAvailableGamepasses(availableGamepasses)
end

function ga:startNewSession(player: Player, gaData)
	threading:performTaskOnGAThread(function()
		if not state:isEventSubmissionEnabled() then
			return
		end

		if not state.Initialized then
			logger:w("Cannot start new session. SDK is not initialized yet.")
			return
		end

		state:startNewSession(player, gaData)
	end)
end

function ga:endSession(playerId: number)
	threading:performTaskOnGAThread(function()
		if not state:isEventSubmissionEnabled() then
			return
		end
		state:endSession(playerId)
	end)
end

function ga:filterForBusinessEvent(text: string)
	return string.gsub(text, "[^A-Za-z0-9%s%-_%.%(%)!%?]", "")
end

function ga:addBusinessEvent(playerId: number | BusinessEventOptions, options: BusinessEventOptions?)
	threading:performTaskOnGAThread(function()
		if not state:isEventSubmissionEnabled() then
			return
		end
		if
			not isSdkReady({
				playerId = playerId,
				needsInitialized = true,
				shouldWarn = false,
				message = "Could not add business event",
			})
		then
			if playerId then
				addToInitializationQueueByUserId(playerId, ga.addBusinessEvent, ga, playerId, options)
			else
				addToInitializationQueue(ga.addBusinessEvent, ga, playerId, options)
			end
			return
		end

		if not options then
			return
		end

		-- Send to events
		local amount = options["amount"] or 0
		local itemType = options["itemType"] or ""
		local itemId = options["itemId"] or ""
		local cartType = options["cartType"] or ""
		local USDSpent = math.floor((amount * 0.7) * 0.35)
		local gamepassId = options["gamepassId"] or nil
		local customFields = options["customFields"]

		events:addBusinessEvent(playerId, "USD", USDSpent, itemType, itemId, cartType, customFields)

		if itemType == "Gamepass" and cartType ~= "Website" then
			local player = Players:GetPlayerByUserId(playerId)
			local playerData = store:GetPlayerDataFromCache(playerId)
			if not playerData.OwnedGamepasses then
				playerData.OwnedGamepasses = {}
			end
			table.insert(playerData.OwnedGamepasses, gamepassId)
			store.PlayerCache[playerId] = playerData
			store:SavePlayerData(player)
		end
	end)
end

function ga:addResourceEvent(playerId: number | ResourceEventOptions, options: ResourceEventOptions?)
	threading:performTaskOnGAThread(function()
		if not state:isEventSubmissionEnabled() then
			return
		end
		if
			not isSdkReady({
				playerId = playerId,
				needsInitialized = true,
				shouldWarn = false,
				message = "Could not add resource event",
			})
		then
			if playerId then
				addToInitializationQueueByUserId(playerId, ga.addResourceEvent, ga, playerId, options)
			else
				addToInitializationQueue(ga.addResourceEvent, ga, playerId, options)
			end
			return
		end

		if not options then
			return
		end

		-- Send to events
		local flowType = options["flowType"] or 0
		local currency = options["currency"] or ""
		local amount = options["amount"] or 0
		local itemType = options["itemType"] or ""
		local itemId = options["itemId"] or ""
		local customFields = options["customFields"]

		events:addResourceEvent(playerId, flowType, currency, amount, itemType, itemId, customFields)
	end)
end

function ga:addProgressionEvent(playerId: number | ProgressionEventOptions, options: ProgressionEventOptions?)
	threading:performTaskOnGAThread(function()
		if not state:isEventSubmissionEnabled() then
			return
		end
		if
			not isSdkReady({
				playerId = playerId,
				needsInitialized = true,
				shouldWarn = false,
				message = "Could not add progression event",
			})
		then
			if playerId then
				addToInitializationQueueByUserId(playerId, ga.addProgressionEvent, ga, playerId, options)
			else
				addToInitializationQueue(ga.addProgressionEvent, ga, playerId, options)
			end
			return
		end

		if not options then
			return
		end

		-- Send to events
		local progressionStatus = options["progressionStatus"] or 0
		local progression01 = options["progression01"] or ""
		local progression02 = options["progression02"] or nil
		local progression03 = options["progression03"] or nil
		local score = options["score"] or nil
		local customFields = options["customFields"]

		events:addProgressionEvent(
			playerId,
			progressionStatus,
			progression01,
			progression02,
			progression03,
			score,
			customFields
		)
	end)
end

function ga:addDesignEvent(playerId: number | DesignEventOptions, options: DesignEventOptions?)
	threading:performTaskOnGAThread(function()
		if not state:isEventSubmissionEnabled() then
			return
		end
		if
			not isSdkReady({
				playerId = playerId,
				needsInitialized = true,
				shouldWarn = false,
				message = "Could not add design event",
			})
		then
			if playerId then
				addToInitializationQueueByUserId(playerId, ga.addDesignEvent, ga, playerId, options)
			else
				addToInitializationQueue(ga.addDesignEvent, ga, playerId, options)
			end
			return
		end

		if not options then
			return
		end

		-- Send to events
		local eventId = options["eventId"] or ""
		local value = options["value"] or nil
		local customFields = options["customFields"]

		events:addDesignEvent(playerId, eventId, value, customFields)
	end)
end

function ga:addErrorEvent(playerId: number | ErrorEventOptions, options: ErrorEventOptions?)
	threading:performTaskOnGAThread(function()
		if not state:isEventSubmissionEnabled() then
			return
		end
		if
			not isSdkReady({
				playerId = playerId,
				needsInitialized = true,
				shouldWarn = false,
				message = "Could not add error event",
			})
		then
			if playerId then
				addToInitializationQueueByUserId(playerId, ga.addErrorEvent, ga, playerId, options)
			else
				addToInitializationQueue(ga.addErrorEvent, ga, playerId, options)
			end
			return
		end

		if not options then
			return
		end

		-- Send to events
		local severity = options["severity"] or 0
		local message = options["message"] or ""
		local customFields = options["customFields"]

		events:addErrorEvent(playerId, severity, message, customFields)
	end)
end

function ga:setEnabledDebugLog(flag: boolean)
	if RunService:IsStudio() then
		if flag then
			logger:setDebugLog(flag)
			logger:i("Debug logging enabled")
		else
			logger:i("Debug logging disabled")
			logger:setDebugLog(flag)
		end
	else
		logger:i("setEnabledDebugLog can only be used in studio")
	end
end

function ga:setEnabledInfoLog(flag: boolean)
	if flag then
		logger:setInfoLog(flag)
		logger:i("Info logging enabled")
	else
		logger:i("Info logging disabled")
		logger:setInfoLog(flag)
	end
end

function ga:setEnabledVerboseLog(flag: boolean)
	if flag then
		logger:setVerboseLog(flag)
		logger:ii("Verbose logging enabled")
	else
		logger:ii("Verbose logging disabled")
		logger:setVerboseLog(flag)
	end
end

function ga:setEnabledEventSubmission(flag: boolean)
	threading:performTaskOnGAThread(function()
		if flag then
			state:setEventSubmission(flag)
			logger:i("Event submission enabled")
		else
			logger:i("Event submission disabled")
			state:setEventSubmission(flag)
		end
	end)
end

function ga:setCustomDimension01(playerId: number | CustomDimension, dimension: CustomDimension?)
	threading:performTaskOnGAThread(function()
		if not validation:validateDimension(state._availableCustomDimensions01, dimension) then
			logger:w(
				"Could not set custom01 dimension value to '"
					.. (dimension or "")
					.. "'. Value not found in available custom01 dimension values"
			)
			return
		end

		if
			not isSdkReady({
				playerId = playerId,
				needsInitialized = true,
				shouldWarn = true,
				message = "Could not set custom01 dimension",
			})
		then
			return
		end

		state:setCustomDimension01(playerId, dimension)
	end)
end

function ga:setCustomDimension02(playerId: number | CustomDimension, dimension: CustomDimension?)
	threading:performTaskOnGAThread(function()
		if not validation:validateDimension(state._availableCustomDimensions02, dimension) then
			logger:w(
				"Could not set custom02 dimension value to '"
					.. (dimension or "")
					.. "'. Value not found in available custom02 dimension values"
			)
			return
		end

		if
			not isSdkReady({
				playerId = playerId,
				needsInitialized = true,
				shouldWarn = true,
				message = "Could not set custom02 dimension",
			})
		then
			return
		end

		state:setCustomDimension02(playerId, dimension)
	end)
end

function ga:setCustomDimension03(playerId: number | CustomDimension, dimension: CustomDimension?)
	threading:performTaskOnGAThread(function()
		if not validation:validateDimension(state._availableCustomDimensions03, dimension) then
			logger:w(
				"Could not set custom03 dimension value to '"
					.. (dimension or "")
					.. "'. Value not found in available custom03 dimension values"
			)
			return
		end

		if
			not isSdkReady({
				playerId = playerId,
				needsInitialized = true,
				shouldWarn = true,
				message = "Could not set custom03 dimension",
			})
		then
			return
		end

		state:setCustomDimension03(playerId, dimension)
	end)
end

function ga:setEnabledReportErrors(flag: boolean)
	threading:performTaskOnGAThread(function()
		state.ReportErrors = flag
	end)
end

function ga:setEnabledCustomUserId(flag: boolean)
	threading:performTaskOnGAThread(function()
		state.UseCustomUserId = flag
	end)
end

function ga:setEnabledAutomaticSendBusinessEvents(flag: boolean)
	threading:performTaskOnGAThread(function()
		state.AutomaticSendBusinessEvents = flag
	end)
end

function ga:addGameAnalyticsTeleportData(playerIds: { number }, teleportData: TeleportData)
	local gameAnalyticsTeleportData = {}
	for _, playerId in ipairs(playerIds) do
		local PlayerData = store:GetPlayerDataFromCache(playerId)
		PlayerData.PlayerTeleporting = true
		local data = {
			["SessionID"] = PlayerData.SessionID,
			["Sessions"] = PlayerData.Sessions,
			["SessionStart"] = PlayerData.SessionStart,
		}

		gameAnalyticsTeleportData[tostring(playerId)] = data
	end

	teleportData["gameanalyticsData"] = gameAnalyticsTeleportData

	return teleportData
end

function ga:getRemoteConfigsValueAsString(playerId: number | RemoteConfigs, options: RemoteConfigs)
	local key = options["key"] or ""
	local defaultValue = options["defaultValue"] or nil
	return state:getRemoteConfigsStringValue(playerId, key, defaultValue)
end

function ga:isRemoteConfigsReady(playerId: number)
	return state:isRemoteConfigsReady(playerId)
end

function ga:getRemoteConfigsContentAsString(playerId: number)
	return state:getRemoteConfigsContentAsString(playerId)
end

function ga:PlayerJoined(Player: Player)
	local joinData = Player:GetJoinData()
	local teleportData = joinData.TeleportData
	local gaData = nil

	--Variables
	local PlayerData = store:GetPlayerData(Player)

	if teleportData and typeof(teleportData) == "table" then
		gaData = teleportData.gameanalyticsData and teleportData.gameanalyticsData[tostring(Player.UserId)]
	end

	local pd = store:GetPlayerDataFromCache(Player.UserId)
	if pd then
		if gaData then
			pd.SessionID = gaData.SessionID
			pd.SessionStart = gaData.SessionStart
		end
		pd.PlayerTeleporting = false
		return
	end

	local PlayerPlatform = "unknown"
	local isGetPlatformSuccessful, platform = Postie.invokeClient("getPlatform", Player, 5)
	if isGetPlatformSuccessful then
		PlayerPlatform = platform
	end

	--Fill Data
	for key, value in pairs(store.BasePlayerData) do
		if PlayerData[key] then
			continue
		end

		if typeof(value) == "table" then
			PlayerData[key] = utilities:copyTable(value)
		else
			PlayerData[key] = value
		end
	end

	local countryCodeResult, countryCode = pcall(function()
		return LocalizationService:GetCountryRegionForPlayerAsync(Player)
	end)

	if countryCodeResult then
		PlayerData.CountryCode = countryCode
	end

	store.PlayerCache[Player.UserId] = PlayerData

	PlayerData.Platform = (PlayerPlatform == "Console" and "uwp_console")
		or (PlayerPlatform == "Mobile" and "uwp_mobile")
		or (PlayerPlatform == "Desktop" and "uwp_desktop")
		or "uwp_desktop"
	PlayerData.OS = PlayerData.Platform .. " 0.0.0"

	if not countryCodeResult then
		events:addSdkErrorEvent(
			Player.UserId,
			"event_validation",
			"player_joined",
			"string_empty_or_null",
			"country_code",
			""
		)
	end

	local PlayerCustomUserId = ""
	if state.UseCustomUserId then
		local isGetCustomUserIdSuccessful, customUserId = Postie.invokeClient("getCustomUserId", Player, 5)
		if isGetCustomUserIdSuccessful then
			PlayerCustomUserId = customUserId
		end
	end

	if not utilities:isStringNullOrEmpty(PlayerCustomUserId) then
		logger:i("Using custom id: " .. PlayerCustomUserId)
		PlayerData.CustomUserId = PlayerCustomUserId
	end

	ga:startNewSession(Player, gaData)

	OnPlayerReadyEvent = OnPlayerReadyEvent or ReplicatedStorage:WaitForChild("OnPlayerReadyEvent")
	OnPlayerReadyEvent:Fire(Player)

	--Validate
	if state.AutomaticSendBusinessEvents then
		--Website gamepasses
		if PlayerData.OwnedGamepasses == nil then --player is new (or is playing after SDK update)
			PlayerData.OwnedGamepasses = {}
			for _, id in ipairs(state._availableGamepasses) do
				if MKT:UserOwnsGamePassAsync(Player.UserId, id) then
					table.insert(PlayerData.OwnedGamepasses, id)
				end
			end
			--Player's data is now up to date. gamepass purchases on website can now be tracked in future visits
			store.PlayerCache[Player.UserId] = PlayerData
			store:SavePlayerData(Player)
		else
			--build a list of the game passes a user owns
			local currentlyOwned = {}
			for _, id in ipairs(state._availableGamepasses) do
				if MKT:UserOwnsGamePassAsync(Player.UserId, id) then
					table.insert(currentlyOwned, id)
				end
			end

			--make a table so it's easier to compare to stored game passes
			local storedGamepassesTable = {}
			for _, id in ipairs(PlayerData.OwnedGamepasses) do
				storedGamepassesTable[id] = true
			end

			--compare stored game passes to currently owned game passses
			for _, id in ipairs(currentlyOwned) do
				if not storedGamepassesTable[id] then
					table.insert(PlayerData.OwnedGamepasses, id)

					local gamepassInfo = ProductCache[id]

					--Cache
					if not gamepassInfo then
						--Get
						gamepassInfo = MKT:GetProductInfo(id, Enum.InfoType.GamePass)
						ProductCache[id] = gamepassInfo
					end

					ga:addBusinessEvent(Player.UserId, {
						amount = gamepassInfo.PriceInRobux,
						itemType = "Gamepass",
						itemId = ga:filterForBusinessEvent(gamepassInfo.Name),
						cartType = "Website",
					})
				end
			end

			store.PlayerCache[Player.UserId] = PlayerData

			store:SavePlayerData(Player)
		end
	end

	local playerEventQueue = InitializationQueueByUserId[Player.UserId]
	if playerEventQueue then
		InitializationQueueByUserId[Player.UserId] = nil
		for _, queuedFunction in ipairs(playerEventQueue) do
			queuedFunction.Func(unpack(queuedFunction.Args))
		end

		logger:i("Player initialization queue called #" .. #playerEventQueue .. " events")
	end
end

function ga:PlayerRemoved(Player: Player)
	--Save
	store:SavePlayerData(Player)

	local PlayerData = store:GetPlayerDataFromCache(Player.UserId)
	if PlayerData then
		if not PlayerData.PlayerTeleporting then
			ga:endSession(Player.UserId)
		else
			store.PlayerCache[Player.UserId] = nil
			store.DataStoreQueue.RemoveKey(Player.UserId)
		end
	end
end

function ga:isPlayerReady(playerId: number)
	if store:GetPlayerDataFromCache(playerId) then
		return true
	else
		return false
	end
end

function ga:ProcessReceiptCallback(Info: ProcessReceiptInfo)
	--Variables
	local ProductInfo = ProductCache[Info.ProductId] :: ProductInfo?

	--Cache
	if not ProductInfo then
		--Get
		pcall(function()
			ProductInfo = MKT:GetProductInfo(Info.ProductId, Enum.InfoType.Product)
			ProductCache[Info.ProductId] = ProductInfo
		end)
	end

	if ProductInfo then
		ga:addBusinessEvent(Info.PlayerId, {
			amount = Info.CurrencySpent,
			itemType = "DeveloperProduct",
			itemId = ga:filterForBusinessEvent(ProductInfo.Name),
		})
	end
end

--customGamepassInfo argument to optinaly provide our own name or price
function ga:GamepassPurchased(player: Player, id: number, customGamepassInfo: ProductInfo?)
	local gamepassInfo = ProductCache[id]

	--Cache
	if not gamepassInfo then
		--Get
		gamepassInfo = MKT:GetProductInfo(id, Enum.InfoType.GamePass)
		ProductCache[id] = gamepassInfo
	end

	local amount = 0
	local itemId = "GamePass"
	if customGamepassInfo then
		amount = customGamepassInfo.PriceInRobux
		itemId = customGamepassInfo.Name
	elseif gamepassInfo then
		amount = gamepassInfo.PriceInRobux
		itemId = gamepassInfo.Name
	end

	ga:addBusinessEvent(player.UserId, {
		amount = amount or 0,
		itemType = "Gamepass",
		itemId = ga:filterForBusinessEvent(itemId),
		gamepassId = id,
	})
end

local requiredInitializationOptions = { "gameKey", "secretKey" }

function ga:initServer(gameKey: string, secretKey: string)
	ga:initialize({
		gameKey = gameKey,
		secretKey = secretKey,
	})
end

function ga:initialize(options: GameAnalyticsOptions)
	threading:performTaskOnGAThread(function()
		for _, option in ipairs(requiredInitializationOptions) do
			if options[option] == nil then
				logger:e("Initialize '" .. option .. "' option missing")
				return
			end
		end
		if options.enableInfoLog ~= nil and options.enableInfoLog then
			ga:setEnabledInfoLog(options.enableInfoLog)
		end
		if options.enableVerboseLog ~= nil and options.enableVerboseLog then
			ga:setEnabledVerboseLog(options.enableVerboseLog)
		end
		if options.availableCustomDimensions01 ~= nil and #options.availableCustomDimensions01 > 0 then
			ga:configureAvailableCustomDimensions01(options.availableCustomDimensions01)
		end
		if options.availableCustomDimensions02 ~= nil and #options.availableCustomDimensions02 > 0 then
			ga:configureAvailableCustomDimensions02(options.availableCustomDimensions02)
		end
		if options.availableCustomDimensions03 ~= nil and #options.availableCustomDimensions03 > 0 then
			ga:configureAvailableCustomDimensions03(options.availableCustomDimensions03)
		end
		if options.availableResourceCurrencies ~= nil and #options.availableResourceCurrencies > 0 then
			ga:configureAvailableResourceCurrencies(options.availableResourceCurrencies)
		end
		if options.availableResourceItemTypes ~= nil and #options.availableResourceItemTypes > 0 then
			ga:configureAvailableResourceItemTypes(options.availableResourceItemTypes)
		end
		if options.build ~= nil and #options.build > 0 then
			ga:configureBuild(options.build)
		end
		if options.availableGamepasses ~= nil and #options.availableGamepasses > 0 then
			ga:configureAvailableGamepasses(options.availableGamepasses)
		end
		if options.enableDebugLog ~= nil then
			ga:setEnabledDebugLog(options.enableDebugLog)
		end

		if options.automaticSendBusinessEvents ~= nil then
			ga:setEnabledAutomaticSendBusinessEvents(options.automaticSendBusinessEvents)
		end
		if options.reportErrors ~= nil then
			ga:setEnabledReportErrors(options.reportErrors)
		end

		if options.useCustomUserId ~= nil then
			ga:setEnabledCustomUserId(options.useCustomUserId)
		end

		if isSdkReady({ needsInitialized = true, shouldWarn = false }) then
			logger:w("SDK already initialized. Can only be called once.")
			return
		end

		local gameKey = options["gameKey"]
		local secretKey = options["secretKey"]

		if not validation:validateKeys(gameKey, secretKey) then
			logger:w(
				"SDK failed initialize. Game key or secret key is invalid. Can only contain characters A-z 0-9, gameKey is 32 length, secretKey is 40 length. Failed keys - gameKey: "
					.. gameKey
					.. ", secretKey: "
					.. secretKey
			)
			return
		end

		events.GameKey = gameKey
		events.SecretKey = secretKey

		state.Initialized = true

		-- New Players
		Players.PlayerAdded:Connect(function(Player)
			ga:PlayerJoined(Player)
		end)

		-- Players leaving
		Players.PlayerRemoving:Connect(function(Player)
			ga:PlayerRemoved(Player)
		end)

		-- Fire for players already in game
		for _, Player in ipairs(Players:GetPlayers()) do
			coroutine.wrap(ga.PlayerJoined)(ga, Player)
		end

		for _, queuedFunction in ipairs(InitializationQueue) do
			task.spawn(queuedFunction.Func, unpack(queuedFunction.Args))
		end
		logger:i("Server initialization queue called #" .. #InitializationQueue .. " events")
		InitializationQueue = {}

		events:processEventQueue()
	end)
end

if not ReplicatedStorage:FindFirstChild("GameAnalyticsRemoteConfigs") then
	--Create
	local f = Instance.new("RemoteEvent")
	f.Name = "GameAnalyticsRemoteConfigs"
	f.Parent = ReplicatedStorage
end

if not ReplicatedStorage:FindFirstChild("OnPlayerReadyEvent") then
	--Create
	local f = Instance.new("BindableEvent")
	f.Name = "OnPlayerReadyEvent"
	f.Parent = ReplicatedStorage
end

task.spawn(function()
	local currentHour = math.floor(os.time() / 3600)
	ErrorDS = store:GetErrorDataStore(currentHour)

	while task.wait(ONE_HOUR_IN_SECONDS) do
		currentHour = math.floor(os.time() / 3600)
		ErrorDS = store:GetErrorDataStore(currentHour)
		errorCountCache = {}
		errorCountCacheKeys = {}
	end
end)

task.spawn(function()
	while task.wait(store.AutoSaveData) do
		for _, key in pairs(errorCountCacheKeys) do
			local errorCount = errorCountCache[key]
			local step = errorCount.currentCount - errorCount.countInDS
			errorCountCache[key].countInDS = store:IncrementErrorCount(ErrorDS, key, step)
			errorCountCache[key].currentCount = errorCountCache[key].countInDS
		end
	end
end)

local function ErrorHandler(message, trace, scriptName, player)
	local scriptNameTmp = "(null)"
	if scriptName ~= nil then
		scriptNameTmp = scriptName
	end
	local messageTmp = "(null)"
	if message ~= nil then
		messageTmp = message
	end
	local traceTmp = "(null)"
	if trace ~= nil then
		traceTmp = trace
	end
	local m = scriptNameTmp .. ": message=" .. messageTmp .. ", trace=" .. traceTmp
	if #m > 8192 then
		m = string.sub(m, 1, 8192)
	end

	local userId = nil
	if player then
		userId = player.UserId
		m = m:gsub(player.Name, "[LocalPlayer]") -- so we don't flood the same errors with different player names
	end

	local key = m
	if #key > 50 then
		key = string.sub(key, 1, 50)
	end

	if errorCountCache[key] == nil then
		errorCountCacheKeys[#errorCountCacheKeys + 1] = key
		errorCountCache[key] = {}
		errorCountCache[key].countInDS = 0
		errorCountCache[key].currentCount = 0
	end

	-- don't report error if limit has been exceeded
	if errorCountCache[key].currentCount > MaxErrorsPerHour then
		return
	end

	ga:addErrorEvent(userId, {
		severity = ga.EGAErrorSeverity.error,
		message = m,
	})

	-- increment error count
	errorCountCache[key].currentCount = errorCountCache[key].currentCount + 1
end

local function ErrorHandlerFromServer(message, trace, Script)
	--Validate
	if not state.ReportErrors then
		return
	end

	if not Script then -- don't remember if this check is necessary but must have added it for a reason
		return
	end

	local scriptName = nil
	local ok, _ = pcall(function()
		scriptName = Script:GetFullName() -- CoreGui.RobloxGui.Modules.PlayerList error, can't get name because of security permission
	end)
	if not ok then
		return
	end

	return ErrorHandler(message, trace, scriptName)
end

local function ErrorHandlerFromClient(message, trace, scriptName, player)
	--Validate
	if not state.ReportErrors then
		return
	end

	return ErrorHandler(message, trace, scriptName, player)
end

--Error Logging
ScriptContext.Error:Connect(ErrorHandlerFromServer)
if not ReplicatedStorage:FindFirstChild("GameAnalyticsError") then
	--Create
	local f = Instance.new("RemoteEvent")
	f.Name = "GameAnalyticsError"
	f.Parent = ReplicatedStorage
end

ReplicatedStorage.GameAnalyticsError.OnServerEvent:Connect(function(player, message, trace, scriptName)
	ErrorHandlerFromClient(message, trace, scriptName, player)
end)

--Record Gamepasses.
MKT.PromptGamePassPurchaseFinished:Connect(function(Player, ID, Purchased)
	--Validate
	if not state.AutomaticSendBusinessEvents or not Purchased then
		return
	end

	ga:GamepassPurchased(Player, ID)
end)

return ga
