config = config

-- DYNAMIC VARIABLES
-- If VMS detected if Sonoran was running for exports, and was able to execute registerApiType.
local sonorancadConnected = false

-- DYNAMIC TABLES
-- Stores checkIds (dept..municipality..model..unmarked?) for race condition mitigation.
--	currentChecks[checkId] = true // nil
local currentChecks = { }

-- Stores users source which are currently checking out;
--	usersCheckingOut[source] = true // nil
local usersCheckingOut = { }

-- Stores players sources who are currently in tablet.
-- 	usersInTablet[source] = true // nil
local usersInTablet = { }

--	Stores players source who are currently tracking a vehicle.
--	usersTracking[source] = vehicleNetId // nil
local usersTracking = { }

--	Stores players source who are media members.
--	mediaMember[source] = true // nil
local mediaMember = { }

-- Stores current checked out vehicles; keys are vehicles network ids.
--[[	checkedOut[vehicle] = { 
								player = <source>, 
								playerName = <playerName>, 
								discord = <discordId>, 
								department = 'MP', 
								municipality = 'DPD', 
								fleetId = <fleetId>
								model = 'dpd1', 
								unmarked = false, 
								livery = <liveryId>, 
								timer = false // <minutes remaining>, 
								location = <vec3>, 
								locationAge = <timeStamp>, 
								direction = 'N', 
								speed = 35
							 }
]]
local checkedOut = { }

-- Stores time players joined their current municipality
--[[	municipalityPlayerTimes[discord] = { 
												clockedInTime = <timestamp>, 
												municipality = <municipalityAbrevName> 
												lastActiveTime = <timeStamp> 
											}
]]
local municipalityPlayerTimes = { }

-- Used in heading -> cardinal direction calculations
local directionLookup = {
  [0] = 'N', [22.5] = 'N', [45] = 'NE', [67.5] = 'NE',
  [90] = 'E', [112.5] = 'E', [135] = 'SE', [157.5] = 'SE',
  [180] = 'S', [202.5] = 'S', [225] = 'SW', [247.5] = 'SW',
  [270] = 'W', [292.5] = 'W', [315] = 'NW', [337.5] = 'NW',
  [360] = 'N'
}

------------------------------------------------------------------------------------------------------
--[[EVENTS]]
------------------------------------------------------------------------------------------------------
-- Returns current vehicle table.
RegisterServerEvent("vms:GetCurrentTable")
AddEventHandler("vms:GetCurrentTable", function(openDisplay)
	local player = source
	-- districtCoverageData is player name and municipality name. It is only used for MP but the 
	-- server does not know the players intended department, as such return to all and discard client side.
	districtCoverageData = { }
	for vehicleNetId, reservation in pairs(checkedOut) do
		local playerRecord = {
			playerName = reservation.playerName,
		}
		
		local municipalityIndex = municipalityIndices[reservation.municipality]
		if municipalityIndex ~= nil then
			municipalityData = departments[reservation.department][municipalityIndex]
			playerRecord.municipalityFullName = municipalityData.fullName
		else
			log.error('Failed to find municipalityIndex on', reservation.municipality, '(vms:GetCurrentTable->districtCoverageDataGen)')
		end

		table.insert(districtCoverageData, playerRecord)
	end

	TriggerClientEvent('vms:ReturnCurrentTable', player, departments, openDisplay, districtCoverageData)
end)

-- Vehicle Spawn, store checkedOut record.
-- 	vMenu->VMS: info for storage with vehicle
RegisterServerEvent("vms:VehicleSpawnedServer")
AddEventHandler("vms:VehicleSpawnedServer", function(vehicleNetId, fleetId, model, livery)
    local player = source
    local playerName = GetPlayerName(player)
	local discord = GetDiscordId(player)
	
	local vehicleEntity 	= NetworkGetEntityFromNetworkId(vehicleNetId)
	local department 		= vehicles[fleetId].department
	local municipality 		= vehicles[fleetId].municipality
	local unmarked 			= vehicles[fleetId].unmarked
	local checkedOutTime 	= os.time()

	checkedOut[vehicleNetId] = 
	{ 
		player 			= player, 
		playerName 		= playerName, 
		discord 		= discord, 
		department 		= department, 
		municipality 	= municipality,
		fleetId 		= fleetId,
		model 			= model, 
		unmarked 		= unmarked, 
		livery 			= livery,
		timer 			= false,
		location 		= GetEntityCoords(vehicleEntity),
		locationAge 	= os.date('!%Y-%m-%dT%H:%M:%SZ'),
		direction 		= GetCardinalDirection(GetEntityHeading(vehicleEntity)),
		speed 			= math.floor(GetEntitySpeed(vehicleEntity) * 2.236936),
		checkedOutTime	= checkedOutTime
	}
	
	local returnedReservation = 
	{
		vehicleNetId 	= vehicleNetId, 
		fleetId 		= fleetId,
		department 		= department, 
		municipality 	= municipality, 
		model 			= model, 
		timer 			= false,
		checkedOutTime 	= checkedOutTime
	}
	log.info('New checkout:', GetPlayerName(player), department, municipality, model, fleetId, '(VehicleSpawnedServer)') --livery)
	TriggerClientEvent('vms:VehicleSpawnedClient', player, returnedReservation)

	-- Time on municipality: sets municipality and time of first check out.
	--	do not differentiate SP "municipalities" since they are just unmarked vs marked sections.
	if department == "SASP" then
		municipality = "SP"
	end
	if discord ~= false then
		if municipalityPlayerTimes[discord] == nil then
			-- first time on duty.
			municipalityPlayerTimes[discord] = { clockedInTime = checkedOutTime, municipality = municipality, lastActiveTime = false }
			log.info(playerName, 'clocked on as', municipality, checkedOutTime, '(VehicleSpawnedServer->municipalityPlayerTimes)')
		elseif municipalityPlayerTimes[discord].municipality ~= municipality then
			-- switched municipalities reset clockedInTime.
			municipalityPlayerTimes[discord] = { clockedInTime = checkedOutTime, municipality = municipality, lastActiveTime = false }
			log.info(playerName, 'switched municipalities, resetting time on municipality', municipality, checkedOutTime, '(VehicleSpawnedServer->municipalityPlayerTimes)')
		end
	end
end)

-- Check-In Vehicle
-- 	If no discord find it, search. If check-in failed return error code.
-- 	Returns: 1 - ok, 0 - no reservation found, -1 - check-in failed (CheckInVehicle).
RegisterServerEvent("vms:CheckInVehicle")
AddEventHandler("vms:CheckInVehicle", function(vehicleNetId)
	local player = source
	local vehicleNetId = vehicleNetId
	local reservation = checkedOut[vehicleNetId]
	
	if not reservation then
		local discord = GetDiscordId(player)
		vehicleNetId, reservation = GetExistingReservation(discord)

		if not reservation then
			log.error('Unable to locate vehicle or players reservation player:', GetPlayerName(player), '(CheckInVehicle)')
			TriggerClientEvent('vms:CheckInVehicleResult', player, 0)
			return
		end
	end
	
	local result = CheckInVehicle(vehicleNetId, true)
	if result then
		log.info(GetPlayerName(player), 'checked in', reservation.model, '(CheckInVehicle)')
		TriggerClientEvent('vms:CheckInVehicleResult', player, 1)
	else
		log.error(GetPlayerName(player), 'check-in failed.', reservation, '(CheckInVehicle)')
		TriggerClientEvent('vms:CheckInVehicleResult', player, -1)
	end
end)

-- Check-out Vehicle
-- 	Create vehicle unique check ID, if ID is in use (another player checking out),
-- 	Wait up to 1 second for previous checkout to finish processing.
RegisterServerEvent("vms:ValidateCheckout")
AddEventHandler("vms:ValidateCheckout", function(fleetId, department, municipality)	
	local player = source
	-- verify another user isn't current checkingout, race condition
	if currentChecks[fleetId] ~= nil then
		log.info('Waiting for check to complete...', json.encode(currentChecks), '(ValidateCheckout)')
		local limit = GetGameTimer() + 1000
		while currentChecks[fleetId] ~= nil and limit > GetGameTimer() do
			Wait(100)
		end
	end
	
	log.info('New check starting:', GetPlayerName(player), fleetId, '(ValidateCheckout)')
	currentChecks[fleetId] = true
	ProcessCheck(player, fleetId, department, municipality)	
end)


-- On resource start return current reservation if found.
-- 	Find reservation by discord, return if any.
RegisterServerEvent("vms:GetExistingReservation")
AddEventHandler("vms:GetExistingReservation", function()
	local player = source
	local playerName = GetPlayerName(player)
	local discord = GetDiscordId(player)
	local vehicle, reservation = GetExistingReservation(discord)
	log.info('Searching for reservation for', playerName, discord, '(GetExistingReservation)')

	if reservation then
		log.info('reservation located.', playerName, reservation, '(GetExistingReservation)')
		local reservationData =
		{
			vehicleNetId 	= vehicle,
			fleetId 		= reservation.fleetId,
			department 		= reservation.department,
			municipality 	= reservation.municipality,
			model 			= reservation.model,
			timer 			= reservation.timer,
			checkedOutTime 	= reservation.checkedOutTime
		}	
		
		TriggerClientEvent('vms:GetExistingReservation', player, departments, reservationData)
	else
		log.info('No reservation found for:', playerName, '(GetExistingReservation)')
	end
end)

-- On resource start return current reservation if found.
-- 	Find reservation by discord, return if any.
RegisterServerEvent("vms:RecoveredReservation")
AddEventHandler("vms:RecoveredReservation", function()
	local player = source
	local discord = GetDiscordId(player)
	local vehicle, reservation = GetExistingReservation(discord)
	
	if not reservation.blockReset then
		-- Update player values and cancel timer.
		local playerName = GetPlayerName(player)
		checkedOut[vehicle].player = player
		checkedOut[vehicle].timer = false
		checkedOut[vehicle].playerName = playerName
		
		-- Update status to reserved.
		TriggerClientEvent('vms:UpdateReservationStatus', player, false, nil, nil)
		log.info(playerName, 'recovered their reservation/vehicle:', checkedOut[vehicle].model, '(RecoveredReservation)')
	end
end)

-- Validate reservation, return vehicle data for use in existing event.
RegisterServerEvent("vms:RespawnVehicle")
AddEventHandler("vms:RespawnVehicle", function()
	local player = source
	local discord = GetDiscordId(player)
	local vehicle, reservation = GetExistingReservation(discord)
	local vehicleEntity = NetworkGetEntityFromNetworkId(vehicle)

	if reservation.blockReset then
		TriggerClientEvent('vms:ReturnValidationResult', player, -3)
		return
	end

	if reservation then
		local returnData = { 
			fleetId = reservation.fleetId,
			model = reservation.model,
			initLivery = reservation.livery
		}
		
		-- Protect against respawning of stolen vehicle, must only be occupied by owner or no one.
		local occupiedByOwner, occupiedByOther, _ = GetVehicleOccupancy(vehicleEntity, reservation.player)
		
		if occupiedByOther then
			log.info(GetPlayerName(player), 'was prevented from spawning another vehicle, occupied by another player', returnData.model, '(RespawnVehicle)')
			TriggerClientEvent('vms:ReturnValidationResult', player, -1)
		else
			local result = CheckInVehicle(vehicle, true)
			if result == 1 then
				log.info(GetPlayerName(player), 'is respawning', returnData.model, 'clearing existing reservations. (RespawnVehicle)')
				TriggerClientEvent('vms:ReturnValidationResult', player, 1, returnData)
			else
				log.error(GetPlayerName(player), 'was unable to respawn vehicle, unexpected error.', returnData.model, '(RespawnVehicle)')
				TriggerClientEvent('vms:ReturnValidationResult', player, -2)
			end
		end
	else
		log.error('Unable to locate players reservation player:', GetPlayerName(player), '(RespawnVehicle)')
	end
end)

-- Enroll users in interval updates.
RegisterServerEvent("vms:SetUpdateRequests")
AddEventHandler("vms:SetUpdateRequests", function(state)
	local player = source
	if state then
		usersCheckingOut[player] = true
	else
		usersCheckingOut[player] = nil
	end
end)

-- Getter for vMenu restricted vehicles list.
RegisterServerEvent("vms:GetBlockedVehicles")
AddEventHandler("vms:GetBlockedVehicles", function()
	if config.vMenuRestrictions then
		TriggerClientEvent("vms:ReceiveBlockedVehicles", source, blockedVehicles, blockedVehicleHashes)
	end
end)

-- Enroll users in tablet updates.
RegisterServerEvent('vms:SetTabletUpdateRequests')
AddEventHandler('vms:SetTabletUpdateRequests', function(state)
	local player = source
	if state then
		usersInTablet[player] = true
	else
		usersInTablet[player] = nil
	end
end)

-- Manager OOS handler, checkout vehicle.
RegisterServerEvent('vms:SubmitNewOOS')
AddEventHandler('vms:SubmitNewOOS', function(oosData)
	local vehicleNetId = oosData.vehicleNetId
	
	if not checkedOut[vehicleNetId] then
		log.error('OOS creation failed, unable to locate vehicle.', vehicleNetId, '(main->vms:SubmitNewOOS)')
		return
	end
	
	checkedOut[vehicleNetId].timer = config.reservationTime
	checkedOut[vehicleNetId].blockReset = true
	log.info('Processing OOS, marking entity for check-in. (main->vms:SubmitNewOOS)')
end)

-- Extend reservation +30 minutes per reservation on command /vmsextend.
RegisterServerEvent('vms:ExtendReservation')
AddEventHandler('vms:ExtendReservation', function(vehicleNetId, isFromNUI)
	local player = source
	if checkedOut[vehicleNetId] then
		local reservation = checkedOut[vehicleNetId]
		if reservation.timer then
			reservation.timer = 30
			TriggerClientEvent('vms:ExtendReservationReturn', player, 1, isFromNUI)
			TriggerClientEvent('vms:UpdateReservationStatus', reservation.player, reservation.timer, nil, nil)
			log.info(reservation.playerName, 'extended their reservation for 30 minutes on', reservation.model, '(ExtendReservation)')
			return
		end
	end
	log.warn(GetPlayerName(player), 'attempted to extend their reservation but was unable on', vehicleNetId, '(ExtendReservation)')
	TriggerClientEvent('vms:ExtendReservationReturn', player, 0, isFromNUI)
end)

-- Single privilege setter on client join.
RegisterServerEvent('vms:GetIsPrivileged')
AddEventHandler('vms:GetIsPrivileged', function()
	local player = source
	local isSupervisor = IsSupervisor(player)
	local isMedia = IsMedia(player)
	local rewardsModels = rewardsBypass[GetDiscordId(player)] or 'none'
	
	if isSupervisor or isMedia or rewardsModels then
		log.warn(GetPlayerName(player), 'is privileged. Media:', isMedia, 'Supervisor:', isSupervisor, 'RewardsModels:', rewardsModels, '(GetIsPrivileged)')
		TriggerClientEvent('vms:IsPrivilegedReturn', player, isSupervisor, isMedia, rewardsModels)

		if isMedia then
			mediaMember[player] = true
		end
	end
end)

-- Enroll user in location tracking updates.
RegisterServerEvent('vms:SetTracking')
AddEventHandler('vms:SetTracking', function(vehicleNetId, state)
	local player = source
	local checkedOutData = checkedOut[vehicleNetId]
	if not checkedOutData then
		log.warn(GetPlayerName(player), 'attempted to set tracking on a vehicle that is no longer reserved.', vehicleNetId, '(SetTracking)')
		return
	end
	
	if state then
		usersTracking[player] = vehicleNetId
		log.info(GetPlayerName(player), 'started tracking', checkedOutData.playerName, checkedOutData.model, '(SetTracking)')
	else
		usersTracking[player] = nil
		log.info(GetPlayerName(player), 'stopped tracking', checkedOutData.playerName, checkedOutData.model, '(SetTracking)')
	end
end)

-- Manage stolen vehicles.
RegisterServerEvent('vms:SetStolenStatus')
AddEventHandler('vms:SetStolenStatus', function(vehicleNetId, state)
	local player = source
	local checkedOutData = checkedOut[vehicleNetId]
	if not checkedOutData then
		log.warn(GetPlayerName(player), 'attempted to change stolen status but reservation was was not found.', vehicleNetId, '(SetStolenStatus)')
		return
	end
	
	if state then
		-- start tracking updates
		usersTracking[player] = vehicleNetId
		log.info(GetPlayerName(player), 'reported their vehicle as stolen, started tracking', checkedOutData.playerName, checkedOutData.model, '(SetTracking)')
		
		-- Notify owner of stolen vehicle, close reservation window, clear reservation.
		TriggerClientEvent('vms:UpdateReservationStatus', checkedOutData.player, 0, true, true)
		
		-- update information
		checkedOutData.stolen = true;
		checkedOutData.player = -2;		
		checkedOutData.playerName = string.format("STOLEN - %s", checkedOutData.playerName);
		checkedOutData.discord = "STOLEN";
	else
		if checkedOutData.stolen then
			usersTracking[player] = nil
			
			-- close tracking for any other users
			for player, trackVehicle in pairs(usersTracking) do
				if trackVehicle == vehicleNetId then
					usersTracking[player] = nil
					TriggerClientEvent('vms:TerminateTracking', player)
				end
			end
			
			checkedOutData.stolen = false;
			checkedOutData.playerName = string.gsub(checkedOutData.playerName, "STOLEN", "RECOVERED");
			checkedOutData.timer = config.reservationTime;
			log.info(GetPlayerName(player), 'recovered stolen vehicle, stopped tracking', checkedOutData.model, '(SetTracking)')
		end
	end
end)

-- Validate if vehicle was spawned through VMS.
RegisterServerEvent('vms:ValidateVehicleLegitimacy')
AddEventHandler('vms:ValidateVehicleLegitimacy', function(vehicleNetId)
	local player = source
	local timeout = GetGameTimer() + 1000
	while timeout > GetGameTimer() do
		if checkedOut[vehicleNetId] ~= nil then
			return
		end
		Wait(100)
	end

	local vehicleEntity = NetworkGetEntityFromNetworkId(vehicleNetId)
	if DoesEntityExist(vehicleEntity) then
		TriggerClientEvent('vms:FailVehicleLegitimacy', player)
		DeleteEntity(vehicleEntity)
	end
	log.warn(GetPlayerName(player), "had a vehicle which was not spawned using VMS as required, deleting entity.", vehicleNetId,  "(ValidateVehicleLegitimacy)")
end) 

-- Player left detection: start reservation timer.
AddEventHandler('playerDropped', function(reason)
	local player = source
	for vehicle, reservation in pairs(checkedOut) do
		if reservation.player == player then
			log.info(GetPlayerName(player), 'left, starting timer. (playerDropped)')
			reservation.timer = config.reservationTime
			return
		end
	end
	
	-- Clear any source based tables
	mediaMember[player]			= nil
	usersTracking[player]		= nil
	usersInTablet[player]		= nil
	usersCheckingOut[player]	= nil
end) 


------------------------------------------------------------------------------------------------------
--[[THREADS]]
------------------------------------------------------------------------------------------------------
-- Initialization: start up actions, register sonoran endpoints.
CreateThread(function()
	local timer = GetGameTimer() + 120000
	while GetResourceState('sonorancad') ~= 'started' and timer < GetGameTimer() do
		Wait(1000)
	end
	
	if GetResourceState('sonorancad') == 'started' then
		exports["sonorancad"]:registerApiType('ADD_BLIP', 'emergency')
		exports["sonorancad"]:registerApiType('MODIFY_BLIP', 'emergency')
		exports["sonorancad"]:registerApiType('REMOVE_BLIP', 'emergency')
		exports["sonorancad"]:registerApiType('GET_BLIPS', 'emergency')
		
		RemoveAllSonoranCADBlips()
		
		sonorancadConnected = true
		log.info('SonoranCAD running and endpoints registered.')
	else
		log.error('Failed to connect to SonoranCAD state:', GetResourceState('sonorancad'))
	end
end)

-- Entity existence, decrement active timers, check-in vehicles on timer completion.
-- 	Notify users of expiration.
-- 	Interval: 1 minute
CreateThread(function()
	while true do
		for vehicleNetId, reservation in pairs(checkedOut) do
			local vehicleEntity = NetworkGetEntityFromNetworkId(vehicleNetId)
			-- lost entity
			if reservation.timer == false then
				if not DoesEntityExist(vehicleEntity) or GetEntityModel(vehicleEntity) ~= GetHashKey(reservation.model) then
					log.info('Entity does not exist, starting timer.', reservation.playerName, reservation.model)
					checkedOut[vehicleNetId].timer = config.reservationTime
				end
			end
			
			-- decrement timer
			if reservation.timer ~= false then 
				if reservation.timer > 0 then
					reservation.timer = reservation.timer - 1
				end

				-- remove reservation / decrement current checked out count
				if reservation.timer < 1 then
					-- if entity is cleaned up ensure sonoran cad blip is removed.
					if reservation.sonoranBlipId then
						reservation.stolen = false
						UpdateSonoranCADLocation(reservation)
					end
				
					local result = CheckInVehicle(vehicleNetId, false)
					if result == 1 then
						log.info(reservation.playerName, 'timer expired, checking in', reservation.model, reservation, '(CheckInVehicle)')
					elseif result == 0 then
						log.info(reservation.playerName, 'timer expired, but still occupied, holding till unoccupied', reservation.model, reservation, '(CheckInVehicle)')
					else
						log.error(reservation.playerName, 'timer expired, but checkin failed.', reservation, '(CheckInVehicle)')
					end
				else
					-- Update reservation display and notifications timer > 0.
					TriggerClientEvent('vms:UpdateReservationStatus', reservation.player, reservation.timer, nil, nil)
				end
			end
		end
		Wait(60000)
	end
end)

-- Sends updated department table for use updating live checkout screen.
-- 	Interval: 5 second
CreateThread(function()
	while true do
		for player, _ in pairs(usersCheckingOut) do
			if GetPlayerName(player) == nil then
				usersCheckingOut[player] = nil
			end
		
			TriggerClientEvent('vms:ReturnCurrentTable', player, departments, true)
		end
		Wait(5000)
	end
end)

-- Send tablet data to enrolled users. Cleanup if player disappeared.
-- 	Interval 5 seconds.
CreateThread(function()
	local checkedOutTablet, municipalityData, vehicleName
	while true do
		local activeUsers = false
		for player, _ in pairs(usersInTablet) do
			activeUsers = true
			break
		end
	
		if activeUsers then
			-- Clean and format data for tablet display.
			checkedOutTablet = { }
			for vehicleNetId, reservation in pairs(checkedOut) do
				local clockedInTime
				if reservation.stolen or municipalityPlayerTimes[reservation.discord] == nil then
					clockedInTime = 'STOLEN/MISSING'
				else
					clockedInTime = municipalityPlayerTimes[reservation.discord].clockedInTime
				end

				checkedOutTablet[vehicleNetId] = {
					fleetId 	 	= reservation.fleetId,
					playerName 		= reservation.playerName,
					unmarked 		= reservation.unmarked,
					timer			= reservation.timer,
					location		= reservation.location,
					locationAge 	= reservation.locationAge,
					direction 		= reservation.direction,
					speed			= reservation.speed,
					stolen			= reservation.stolen or false,
					clockedInTime 	= clockedInTime
				}

				-- Full department & vehicle name.
				local municipalityIndex = municipalityIndices[reservation.municipality]
				if municipalityIndex ~= nil then
					municipalityData = departments[reservation.department][municipalityIndex]
					checkedOutTablet[vehicleNetId].municipalityFullName = municipalityData.fullName
					
					vehicleName = vehicleNames[reservation.model]
					checkedOutTablet[vehicleNetId].model = string.format("%s (%s)", vehicleName.short, reservation.model)
					checkedOutTablet[vehicleNetId].modelFullName = string.format("%s (%s)", vehicleName.long, reservation.model)
				else
					log.error('Failed to find municipalityIndex on', reservation.municipality, '(TabletUpdateThread)')
				end
			end
			
			-- Broadcast data
			for player, _ in pairs(usersInTablet) do
				TriggerClientEvent('vms:TabletDataReturn', player, { checkedOutData = checkedOutTablet, oosData = outOfService })
			end
		end
		Wait(5000)
	end
end)

-- If users in tablet, update data for use in tablet (direction, location, speed).
-- 	Interval 2 seconds.
CreateThread(function()
	local vehicleEntity, heading, direction, position
	
	while true do
		local activeUsers = false
		-- Check if any users in tablet
		for player, _ in pairs(usersInTablet) do
			activeUsers = true
			break
		end	
		
		-- Check if any users tracking
		if not activeUsers then
			for player, _ in pairs(usersTracking) do
				activeUsers = true
				break
			end
		end

		if activeUsers then
			for vehicleNetId, reservation in pairs(checkedOut) do
				vehicleEntity = NetworkGetEntityFromNetworkId(vehicleNetId)
				-- Exists, else network id may have been overridden.
				if DoesEntityExist(vehicleEntity) and GetEntityModel(vehicleEntity) == GetHashKey(reservation.model) then
					heading = GetEntityHeading(vehicleEntity)
					checkedOut[vehicleNetId].location = GetEntityCoords(vehicleEntity)
					checkedOut[vehicleNetId].locationAge = os.date('!%Y-%m-%dT%H:%M:%SZ')
					checkedOut[vehicleNetId].direction = GetCardinalDirection(heading)
					checkedOut[vehicleNetId].speed = math.floor(GetEntitySpeed(vehicleEntity) * 2.236936)
					
					-- stolen vehicle, or existing blip to remove
					if reservation.stolen or reservation.sonoranBlipId then
						UpdateSonoranCADLocation(checkedOut[vehicleNetId])
					end
				else
					-- If lost, reset speed to 0.
					checkedOut[vehicleNetId].speed = 0
				end
			end
		end
		Wait(5000)
	end
end)

-- Send tracking information. Cleanup if player disappeared.
-- 	Interval 5 seconds.
CreateThread(function()
	local trackingData, checkedOutData
	while true do
		for player, vehicleNetId in pairs(usersTracking) do
			checkedOutData = checkedOut[vehicleNetId]
			if checkedOutData == nil then
				usersTracking[player] = nil
				TriggerClientEvent('vms:TerminateTracking', player)
				log.info('Terminating tracking updates for', GetPlayerName(player), 'on', vehicleNetId, 'reservation lost. (TrackingUpdatesThread)')
			else
				-- Find municipality full name
				local municipalityFullName = 'UKNOWN'
				local municipalityIndex = municipalityIndices[checkedOutData.municipality]
				if municipalityIndex ~= nil then
					municipalityData = departments[checkedOutData.department][municipalityIndex]
					municipalityFullName = municipalityData.fullName
				else
					log.error('Failed to find municipalityIndex on', checkedOutData.municipality, '(TrackingUpdatesThread)')
				end
				
				local modelFullName = string.format('%s %s (%s)', checkedOutData.unmarked and 'UM' or '', vehicles[checkedOutData.fleetId].abbrevName, checkedOutData.model)
				trackingData = {
					location 	= checkedOutData.location,
					locationAge = checkedOutData.locationAge,
					speed 		= checkedOutData.speed,
					direction 	= checkedOutData.direction,
					model 		= modelFullName,
					municipalityFullName = municipalityFullName
				}
				TriggerClientEvent('vms:TrackingDataReturn', player, trackingData)
			end
		end
		Wait(5000)
	end
end)

-- Clean up municipalityPlayerTimes. We set the lastActiveTime in ProcessSuccessfulCheckIn. After 30 minutes of no activity clear their municipalityPlayerTime table.
-- 	Interval 5 minutes.
CreateThread(function()
	while true do
		for discord, playerTimeData in pairs(municipalityPlayerTimes) do
			local lastActive = playerTimeData.lastActiveTime
			if lastActive ~= false then
				--  last active was longer than 30 minutes ago
				if lastActive < (os.time() - (30 * 60)) then
					municipalityPlayerTimes[discord] = nil
					log.info('Cleaning up', discord, 'from municipalityPlayerTimes last seen 30 minutes ago,', lastActive, '(municipalityPlayerTimes)')
				end
			end
		end
		Wait(180000)
	end
end)

------------------------------------------------------------------------------------------------------
--[[Functions]]
------------------------------------------------------------------------------------------------------
-- ProcessSuccessfulCheckIn: check-in succeeded update service hours, decrement counters for vehicle, 
--	max if bypassed, and municipality.
local function ProcessSuccessfulCheckIn(reservation)
	local fleetId = reservation.fleetId
	local vehicleTable = vehicles[fleetId]

	-- Update service hours, multiply by 12 to get IRL to RP time scale.
	local elapsedSeconds = (os.time() - reservation.checkedOutTime) * 12
	local elapsedHours = math.floor((elapsedSeconds / 3600) * 10 + 0.5) / 10
	UpdateServiceHours({fleetId = fleetId, elapsedHours = elapsedHours})
	
	if vehicleTable.current > 0 then
		vehicleTable.current = vehicleTable.current - 1
		
		-- decrement max counter for bypass
		local player = reservation.player
		if mediaMember[player] or CheckIfRewardVehicle(player, fleetId) then
			vehicleTable.max = vehicleTable.max - 1
		end
		
		-- decrement municipality counter
		local municipalityIndex = municipalityIndices[reservation.municipality]
		local municipalityData = departments[reservation.department][municipalityIndex]
		if municipalityData then
			municipalityData.count = math.max(municipalityData.count - 1, 0)
		else
			log.error('Failed to locate municipality', reservation.department, reservation.municipality, municipalityIndex, '(ProcessSuccessfulCheckIn)')
		end
	end

	-- Set municipality lastActiveTime so we can clean up municipalityPlayerTimes if a player leaves.
	if municipalityPlayerTimes[reservation.discord] ~= nil then
		municipalityPlayerTimes[reservation.discord].lastActiveTime = os.time()
		log.info('Set lastTimeActive for', reservation.playerName, municipalityPlayerTimes[reservation.discord].lastActiveTime, '(ProcessSuccessfulCheckIn)')
	end
end

-- Check-in Vehicle: remove checkout data, delete vehicle if possible, notify users of blocked check-in.
--	Returns -1: ERROR, 0: Blocked by occupant;, 1: success
function CheckInVehicle(vehicleNetId, forced)
	local reservation = checkedOut[vehicleNetId]
	local fleetId = reservation.fleetId
	local vehicleTable = vehicles[fleetId]
	local vehicleEntity = NetworkGetEntityFromNetworkId(vehicleNetId)
		
	if not vehicleTable then
		log.error('FleetId could not be located in vehicles table.', fleetId, reservation.model, '(CheckInVehicle)')
		return -1
	end
	
	-- Check if player is in entity.
	if DoesEntityExist(vehicleEntity) then
		local occupiedByOwner, occupiedByOther, occupiedPlayers = GetVehicleOccupancy(vehicleEntity, reservation.player)
		
		if not (occupiedByOwner or occupiedByOther) or forced then
			DeleteEntity(vehicleEntity)
			
			-- Process data, update counters and service hours
			ProcessSuccessfulCheckIn(reservation)
			
			-- Clear client reservation, close window or notify.
			TriggerClientEvent('vms:UpdateReservationStatus', reservation.player, 0, true, nil)
			
			checkedOut[vehicleNetId] = nil
			log.info('Deleting entity.', vehicleNetId, reservation.model, '(CheckInVehicle)')
			return 1
		else
			for _, player in ipairs(occupiedPlayers) do
				-- Notify of blocked check-in.
				TriggerClientEvent('vms:UpdateReservationStatus', player, 0, nil, nil)
			end
			return 0
		end
	else
		-- Process data, update counters and service hours
		ProcessSuccessfulCheckIn(reservation)
		
		-- Clear client reservation, close window or notify.
		TriggerClientEvent('vms:UpdateReservationStatus', reservation.player, 0, true, nil)
		
		checkedOut[vehicleNetId] = nil
		return 1
	end
end

-- Checkout Validate and Process
-- 	Given parameters to search, perform and make sure limit isn't reached
-- 	Returns: 1 - ok, 0 - limit reached, -1 - error unable to locate vehicle model.
function ProcessCheck(player, fleetId, department, municipality)
	local vehicleTable = vehicles[fleetId]
	local isRewardVehicle = CheckIfRewardVehicle(player, fleetId)
	
	if vehicleTable then
		-- Vehicle is available or media member bypass, or reward and bypass record present.
		if vehicleTable.current < vehicleTable.max or mediaMember[player] or isRewardVehicle then
			-- increment current vehicle count
			vehicleTable.current = vehicleTable.current + 1
			
			-- increment max count for bypass users.
			if mediaMember[player] or isRewardVehicle then
				vehicleTable.max = vehicleTable.max + 1
			end
			
			-- increment municipality counter
			local municipalityIndex = municipalityIndices[municipality]
			if municipalityIndex and departments[department] and departments[department][municipalityIndex] then
				municipalityData = departments[department][municipalityIndex]
				municipalityData.count = municipalityData.count + 1
			else
				log.error('ProcessCheck: failed to locate municipality', department, municipality, municipalityIndex)
			end
			
			-- build client  return data
			local returnData = { 
				fleetId = fleetId,
				model = vehicleTable.model,
				initLivery = vehicleTable.initLivery
			}
			
			TriggerClientEvent('vms:ReturnValidationResult', player, 1, returnData)
			currentChecks[fleetId] = nil
			log.info('ProcessCheck:', 'OK.', GetPlayerName(player), fleetId)
			return
		else
			log.warn('ProcessCheck: max reached, rejecting.', GetPlayerName(player), fleetId)
			TriggerClientEvent('vms:ReturnValidationResult', player, 0)
			currentChecks[fleetId] = nil
			return
		end
	end
	log.error('ProcessCheck: failed to locate vehicle, rejecting.', GetPlayerName(player), fleetId)
	TriggerClientEvent('vms:ReturnValidationResult', player, -1)
	currentChecks[fleetId] = nil
end

--[[ Helper Functions ]]
function GetDiscordId(player)
	if player == nil then
		log.error('Cannot get discord nil source. (GetDiscordId)')
		return false
	end
	
	for k, v in pairs(GetPlayerIdentifiers(player)) do
		if string.sub(v, 1, string.len("discord:")) == "discord:" then
			return string.sub(v, string.len("discord:") + 1)
		end
	end
	log.error('No discord identifier found for:', GetPlayerName(player), '(GetDiscordId)')
	return false
end

function GetExistingReservation(discord)
	for vehicle, reservation in pairs(checkedOut) do
		if discord == reservation.discord then
			return vehicle, reservation
		end
	end
	return false
end

function GetVehicleOccupancy(vehicleEntity, player)
	local occupiedByOwner = false
	local occupiedByOther = false
	local occupiedPlayers = { }

	for seat=-1, 6, 1 do
		local occupantPed = GetPedInVehicleSeat(vehicleEntity, seat)
		if occupantPed ~= 0 then
			if occupantPed == GetPlayerPed(player) then
				occupiedByOwner = true
			else
				occupiedByOther = true
			end
			table.insert(occupiedPlayers, NetworkGetFirstEntityOwner(occupantPed))
		end
	end

	return occupiedByOwner, occupiedByOther, occupiedPlayers
end

-- Has SIT+ permissions
function IsSupervisor(player)
    if exports["framework"]:IsRolePresent(player, "Head Administration") or
        exports["framework"]:IsRolePresent(player, "Administration") or
        exports["framework"]:IsRolePresent(player, "Junior Administration") or
        exports["framework"]:IsRolePresent(player, "Senior Staff") or 
		exports["framework"]:IsRolePresent(player, "Staff")
        or exports["framework"]:IsRolePresent(player, "SIT") then
        return true
    end
	return false
end

-- Has Media perms
function IsMedia(player)
    if exports["framework"]:IsRolePresent(player, "Head Administration") or
	   exports["framework"]:IsRolePresent(player, "Media") then	
        return true
    end
	return false
end

-- Lookup direction from heading
function GetCardinalDirection(heading)
	for degree, direction in pairs(directionLookup) do
		if (math.abs(heading - degree) < 22.5) then
			return direction
		end
	end
end

-- Iterate over rewards and return if matching fleetId
function CheckIfRewardVehicle(player, targetFleetId)
	local discord = GetDiscordId(player)
	local rewards = rewardsBypass[discord] or { }
		
	for _, rewardModel in ipairs(rewards) do
		if vehicles[targetFleetId].model == rewardModel then
			return true
		end
	end
	
	return false
end

-- Manage Sonoran CAD blips for stolen vehicles
function UpdateSonoranCADLocation(checkedOutData)
	-- SonoranCAD not found/running on init, API endpoints not registered.
	if not sonorancadConnected then
		return
	end

	-- Remove recovered vehicles blips
	if checkedOutData.sonoranBlipId and not checkedOutData.stolen then
		exports["sonorancad"]:performApiRequest({{
			["ids"] = { checkedOutData.sonoranBlipId }
		}}, "REMOVE_BLIP", function(res)
			if cb ~= nil then
				cb(res)
				checkedOutData.sonoranBlipId = nil
			end
		end)
		log.info('Removing blip for recovered vehicle', checkedOutData.playerName, checkedOutData.model, '(UpdateSonoranCADLocation)')
		return
	end
	
	-- New blip data
	local vehicleData = vehicles[checkedOutData.fleetId]
	local toolTipString = string.format('STOLEN - %s (%s)', vehicleData.abbrevName, vehicleData.model)
	
	
	local endPoint = "ADD_BLIP"
	local blipdata = {
		{
			serverId = 3,
			blip = {
				id = -1,
				subType = "VMS",
				coordinates = {
					x = checkedOutData.location.x,
					y = checkedOutData.location.y,
				},
				radius = 10,
				icon = 'https://i.imgur.com/3W2JmWY.png',
				color = "#e60000",
				tooltip = toolTipString,
				data = {
					{ title = 'VMS:', text = toolTipString },
					{ title = 'Unmarked', text = vehicleData.unmarked and 'True' or 'False' },
					{ title = 'Owner', text = checkedOutData.playerName:match("^STOLEN%s-%-%s-(.*)$") or checkedOutData.playerName },
					{ title = 'Timestamp', text = os.date('!%Y-%m-%d %H:%M:%SZ') },
				}
			}
		},
	}
	
	-- If blip exists update blip id to current for updating.
	if checkedOutData.sonoranBlipId then
		endPoint = "MODIFY_BLIP"
		blipdata = {
			{
				serverId = 3,
				id = checkedOutData.sonoranBlipId,
				subType = "VMS",
				coordinates = {
					x = checkedOutData.location.x,
					y = checkedOutData.location.y,
				},
				radius = 10,
				icon = 'https://i.imgur.com/3W2JmWY.png',
				color = "#e60000",
				tooltip = toolTipString,
				data = {
					{ title = 'VMS:', text = toolTipString },
					{ title = 'Unmarked', text = vehicleData.unmarked and 'TRUE' or 'FALSE' },
					{ title = 'Owner', text = checkedOutData.playerName:match("^STOLEN%s-%-%s-(.*)$") or checkedOutData.playerName },
					{ title = 'Timestamp', text = os.date('!%Y-%m-%d %H:%M:%SZ') },
				}
			},
		}
	else
		log.info('Adding sonoran blip for stolen vehicle', checkedOutData.playerName, checkedOutData.model, '(UpdateSonoranCADLocation)')
	end
	
	exports["sonorancad"]:performApiRequest(blipdata, endPoint, function(res)
		if cb ~= nil then
			cb(res)
		end
		if endPoint == 'ADD_BLIP' then
			local decodedRes = json.decode(res)
			if type(decodedRes) == "table" and #decodedRes > 0 then
				local blipId = decodedRes[1].id
				checkedOutData.sonoranBlipId = blipId
			else
				log.error('SonoranCAD response could not be decoded', endPoint, res)
			end
		end
	end)
end

-- Remove All Sonoran CAD blips for stolen vehicles
function RemoveAllSonoranCADBlips()
	local removeBlips = { }
    local data = {{
        ["serverId"] = GetConvar("sonoran_serverId", '1')
    }}
    exports["sonorancad"]:performApiRequest(data, "GET_BLIPS", function(res)
        if cb ~= nil then
            cb(res)
        end
		local decodedRes = json.decode(res)
		if type(decodedRes) == "table" and #decodedRes > 0 then
			for _, blip in ipairs(decodedRes) do
				if blip.subType == 'VMS' then
					table.insert(removeBlips, blip.id)
				end
			end
			if #removeBlips > 0 then
				exports["sonorancad"]:performApiRequest({{
					["ids"] = removeBlips
				}}, "REMOVE_BLIP", function(res)
					if cb ~= nil then
						cb(res)
					end
				end)
				log.warn('Cleaned up', #removeBlips, 'abandoned blips. (RemoveAllSonoranCADBlips)')
			end
		end
    end)
end

------------------------------------------------------------------------------------------------------
--[[COMMANDS]]
------------------------------------------------------------------------------------------------------
-- Server Op Command
RegisterCommand('_vmsadmin', function(source, args)
	local player = source
	local argCount = #args

	if player == 0 then
		if argCount == 0 then
			print('^3Invalid args: use "_vmsadmin help" for commands.^7')
			return
		end
		
		local command = args[1]
		-- Print help message
		if command == "help" then
			print('^5Commands:\n\tlogfilelevel <level>\t[info, warn, error]\n\tlogconsolelevel <level>\t[info, warn, error]\n\twritelogfile\n\tdumpcheckedout\n\treset\n\trestrictions <boolean>^7')
		-- Change log level
		elseif command == "logfilelevel" or command == "logconsolelevel" then
			if argCount < 2 then
				print("^3Missing argument: <level> where level is info, warn, error.^7")
				return
			end
			
			if args[2] ~= "info" and args[2] ~= "warn" and args[2] ~= "error" then
				print("^3Invalid argument: <level> where level is info, warn, error.^7")
				return
			end
			
			if command == "logfilelevel" then
				log.fileLevel = args[2]
			else
				log.consoleLevel = args[2]
			end
			print(string.format("^2Log level updated.\n\tfileLevel: %s\n\tconsoleLevel: %s^7", log.fileLevel, log.consoleLevel))
		-- Write log file
		elseif command == "writelogfile" then
			local result, fileName = log.writeToFile()
			if result then
				print("^2Success log file written:", fileName)
			else
				print(string.format("^8Failed to write log file: %s resulted in %s", fileName, result))
			end
		-- Reset live data, checkouts, etc.
		elseif command == "reset" then
			if argCount < 2 then
				print('^8WARNING: YOU ARE ABOUT TO RESET VMS. ALL LIVE DATA WILL BE CLEARED INCLUDING RESERVATIONS.\n\tTo continue execute "^3_vmsadmin reset confirm^8"^7')
			elseif args[2] == 'confirm' then
				checkedOut = { }
				usersCheckingOut = { }
				currentChecks = { }
				print('^2Reset Complete!^7')
				log.warn('RESET OCCURRED, TRIGGERED FROM CONSOLE.')
			end
		elseif command == "restrictions" then
			if argCount > 1 then
				if args[2] == "true" or args[2] == "enable" then
					TriggerClientEvent("vms:ReceiveBlockedVehicles", -1, blockedVehicles, blockedVehicleHashes)
					TriggerServerEvent("wwrp:Log", "VMS: Restrictions", GetPlayerName(player) .. ' enabled vMenu spawning restrictions.', 5763719);
					TriggerClientEvent("chat:addMessage", player, { color = { 255, 0, 0}, multiline = false, args = {"VMS", "Success. vMenu restrictions ^2enabled^7 for all players." } })
					log.warn(GetPlayerName(player), "enabled vMenu spawning restrictions.")
					return
				elseif args[2] == "false" or args[2] == "disable" then
					TriggerClientEvent("vms:ReceiveBlockedVehicles", -1, {}, {})
					TriggerServerEvent("wwrp:Log", "VMS: Restrictions", GetPlayerName(player) .. ' disabled vMenu spawning restrictions.', 16776960);
					TriggerClientEvent("chat:addMessage", player, { color = { 255, 0, 0}, multiline = false, args = {"VMS", "Success. vMenu restrictions ^8disabled^7 for all players." } })
					log.warn(GetPlayerName(player), "disabled vMenu spawning restrictions.")
					return
				end
			else
				print("^3Invalid argument: <true/false>.^7")
			end
		elseif command == "dumpcheckedout" then
			log.info('Dumping Current checkedOutTable...')
			log.info('--------------------------------------')
			log.info(checkedOut)
			log.info('--------------------------------------')
		end
	else 
		-- Client Commands
		if IsSupervisor(player) then
			if argCount > 1 then
				if args[1] == "restrictions" then
					if args[2] == "true" or args[2] == "enable" then
					TriggerClientEvent("vms:ReceiveBlockedVehicles", -1, blockedVehicles, blockedVehicleHashes)
						TriggerClientEvent("chat:addMessage", player, { color = { 255, 0, 0}, multiline = false, args = {"VMS", "Success. vMenu restrictions ^2enabled^7 for all players." } })
						log.warn(GetPlayerName(player), "enabled vMenu spawning restrictions.")
						return
					elseif args[2] == "false" or args[2] == "disable" then
						TriggerClientEvent("vms:ReceiveBlockedVehicles", -1, {}, {})
						TriggerClientEvent("chat:addMessage", player, { color = { 255, 0, 0}, multiline = false, args = {"VMS", "Success. vMenu restrictions ^8disabled^7 for all players." } })
						log.warn(GetPlayerName(player), "disabled vMenu spawning restrictions.")
						return
					end
				end
			end
		end
		log.warn(GetPlayerName(player), "ran management command with args", args)
		TriggerClientEvent("chat:addMessage", player, { color = { 255, 0, 0}, multiline = false, args = {"VMS", "Invalid command or the command is only accessible through the server console." } })
	end
end, false)
