--[[

Inventory Sync

When a player leaves the game, serialize the player state and upload it to the controller.
When a player joins the game, request the player data from controller and deserialize the player state from it.

]]

local compat = require("modules/clusterio/compat")
local clusterio_api = require("modules/clusterio/api")
local download_inventory = require("modules/inventory_sync/download_inventory")
local serialize = require("modules/inventory_sync/serialize")
local restore_position = require("modules/inventory_sync/restore_position")
local get_script_data = require("modules/inventory_sync/get_script_data")
local progress_dialog = require("modules/inventory_sync/gui/progress_dialog")
local dialog_failed_download = require("modules/inventory_sync/gui/dialog_failed_download")

-- Returns true if the player is currently in a cutscene
local function is_in_cutscene(player)
	return player.controller_type == defines.controllers.cutscene
end

-- Create player record for bookkeeping
local function create_player(player, dirty)
	local script_data = get_script_data(true) -- no early return to support loading from single player
	script_data.players[player.name] = {
		dirty = dirty, -- Player inventory has changes that should be persisted
		sync = false, -- Player inventory is synced from the controller
		generation = 0,
	}
end

-- Remove all stored player data by this module
local function remove_player(player)
	local script_data = get_script_data()
	script_data.players_waiting_for_acquire[player.name] = nil
	script_data.players_in_cutscene_to_sync[player.name] = nil
	script_data.active_downloads[player.name] = nil
	script_data.finished_downloads[player.name] = nil
	script_data.active_uploads[player.name] = nil
	script_data.players[player.name] = nil
end


inventory_sync = {}
-- This function is called by instance.js as /sc inventory_sync.download_inventory("danielv", Escaped JSON string, package_number, total_packages_count) with data from the controller
inventory_sync.download_inventory = download_inventory

-- This function is called internally when a player leaves the game to
-- serialize the player for upload.
function inventory_sync.serialize_player(player, player_record)
	-- Editor mode is not supported.
	if player.controller_type == defines.controllers.editor then
		player.toggle_map_editor()
	end

	if player.controller_type == defines.controllers.editor then
		error("Unable to switch out of editor mode")
		return
	end

	-- mod-compat: Exit Space Exploration satellite view to restore the real character
	if remote.interfaces["space-exploration"] then
		local status, result = pcall(function()
			if remote.call("space-exploration", "remote_view_is_active", { player = player }) then
				remote.call("space-exploration", "remote_view_stop", { player = player })
			end
		end)
		if not status then
			log("ERROR: Exiting remote view failed for " .. player.name .. ": " .. result)
			player.print("ERROR: Exiting remote view failed: " .. result)
		end
	end

	local serialized_player = serialize.serialize_player(player)
	serialized_player.generation = player_record.generation

	return serialized_player
end

function inventory_sync.deserialize_player(player, finished_record)
	-- Editor mode is not supported.
	if player.controller_type == defines.controllers.editor then
		player.toggle_map_editor()
	end

	if player.controller_type == defines.controllers.editor then
		error("Unable to switch out of editor mode")
		return
	end

	-- Stash temporary inventory if it exists
	local stashed_corpse
	local script_data = get_script_data()
	local player_record = script_data.players[player.name]
	if player_record.dirty and player.character then
		local character = player.character
		local surface = character.surface
		local position = character.position
		local corpse_name = character.prototype.character_corpse.name
		player.character = nil
		character.die()
		stashed_corpse = surface.find_entity(corpse_name, position)
	end

	-- Deserialize downloaded player data
	local serialized_player = compat.json_to_table(finished_record.data)
	serialize.deserialize_player(player, serialized_player)

	-- Restore player position and driving state
	restore_position(player, finished_record)

	-- Transfer items from stashed inventory
	if stashed_corpse then
		local main = player.get_main_inventory()
		local stash = stashed_corpse.get_inventory(defines.inventory.character_corpse)
		if main then
			for i = 1, #stash do
				-- Try transferring a stack
				local source = stash[i]
				if source and source.valid_for_read then
					local target = main.find_empty_stack()
					if not target then
						break
					end
					target.transfer_stack(source)
				end
			end
		end

		if stash.is_empty() then
			stashed_corpse.destroy()
		else
			player.print(
				"Your temporary inventory did not fit into your synced one and the remaining items have " ..
				"been placed in a corpse below you."
			)
		end
	end
end

inventory_sync.events = {}
inventory_sync.events[clusterio_api.events.on_server_startup] = function(event)
	local script_data = get_script_data(true)
	script_data.players_waiting_for_acquire = {}
	script_data.players_in_cutscene_to_sync = {}

	for _, player in pairs(game.players) do
		if not script_data.players[player.name] then
			create_player(player, true)
		end
	end
end

-- Cleanup
inventory_sync.events[defines.events.on_pre_player_removed] = function(event)
	remove_player(game.get_player(event.player_index))
end

inventory_sync.events[defines.events.on_player_created] = function(event)
	create_player(game.get_player(event.player_index), false)
end

function inventory_sync.acquire(player)
	local script_data = get_script_data()
	script_data.players_waiting_for_acquire[player.name] = {
		start_tick = game.ticks_played,
	}
	clusterio_api.send_json("inventory_sync_acquire", {
		player_name = player.name,
	})
end

function inventory_sync.check_players_waiting_for_acquire()
	local script_data = get_script_data()
	for player_name, record in pairs(script_data.players_waiting_for_acquire) do
		-- Check if the acquire player request timed out
		if game.ticks_played > record.start_tick + 600 then
			inventory_sync.acquire_response(compat.table_to_json({
				status = "timeout",
				player_name = player_name,
			}))
		end
	end
end

function inventory_sync.acquire_response(data)
	local script_data = get_script_data()
	local response = assert(compat.json_to_table(data))
	if not script_data.players_waiting_for_acquire[response.player_name] then
		clusterio_api.send_json("inventory_sync_release", { player_name = response.player_name })
		return
	end
	script_data.players_waiting_for_acquire[response.player_name] = nil

	local player = game.get_player(response.player_name)
	if is_in_cutscene(player) then
		response.player = player
		script_data.players_in_cutscene_to_sync[response.player_name] = response
	else
		inventory_sync.sync_player(response)
	end
end

function inventory_sync.check_players_in_cutscene()
	local script_data = get_script_data()
	for player_name, response in pairs(script_data.players_in_cutscene_to_sync) do
		if not is_in_cutscene(response.player) then
			script_data.players_in_cutscene_to_sync[player_name] = nil
			inventory_sync.sync_player(response)
		end
	end
end

function inventory_sync.sync_player(acquire_response)
	local player = assert(game.get_player(acquire_response.player_name))

	-- Editor mode is not supported.
	if player.controller_type == defines.controllers.editor then
		player.toggle_map_editor()
	end

	if player.controller_type == defines.controllers.editor then
		log("ERROR: Inventory sync failed, unable to switch " .. player.name .. " out of editor mode")
		player.print("ERROR: Inventory sync failed, unable to switch out of editor mode")
		return
	end

	local script_data = get_script_data()
	local finished_record = script_data.finished_downloads[acquire_response.player_name]
	if finished_record then
		if acquire_response.status == "acquired" and finished_record.generation == acquire_response.generation then
			inventory_sync.finish_download(player, finished_record)
			return
		end

		-- The acquisition either failed or the current finished download is
		-- stale, delete it and try downloading it again.
		script_data.finished_downloads[acquire_response.player_name] = nil
	end

	local download_record = script_data.active_downloads[acquire_response.player_name]
	if download_record then
		-- Ignore the response if it affirms the current active download
		if acquire_response.status == "acquired" and download_record.generation == acquire_response.generation then
			game.print("Continuing active download")
			return
		end

		-- This is the excaptional case where a long download started from
		-- the previous time the player joined is still ongoing and we just
		-- received a message saying that it's not valid any more.

		if acquire_response.status == "error" or acquire_response.status == "timeout" then
			-- Let the current download finish or fail on its own, we may end up
			-- duplicating items or giving the player a stale inventory here
			-- but it's better than throwing away all the downloaded data.
			return
		end

		if acquire_response.status == "acquired" then
			-- The stored data on the controller has changed since the
			-- download was started. Restart it.
			game.print("Restarting download")
			download_record.restart = true
			return
		end

		if acquire_response.status == "busy" then
			-- Somehow another instance has claimed the player while we're
			-- downloading it. Abort it and send failure to the player.
			script_data.active_downloads[acquire_response.player_name] = nil
		end
	end

	local player_record = script_data.players[acquire_response.player_name]
	if acquire_response.status ~= "acquired" then
		if player_record.sync and not player_record.dirty then
			if player.controller_type == defines.controllers.character then
				local character = player.character
				player.set_controller({ type = defines.controllers.spectator })
				if character then character.destroy() end
			end
			player_record.sync = false
		end
		dialog_failed_download.create(player, acquire_response)
		return
	end

	if
		acquire_response.has_data
		and (not player_record.sync or acquire_response.generation > player_record.generation)
	then
		inventory_sync.initiate_inventory_download(player, player_record, acquire_response.generation)

	else
		if acquire_response.generation == 0 then
			player.print(
				"Your inventory will be automatically synchronized between servers in this cluster. " ..
				"To improve sync performance, please avoid storing large blueprints in your inventory.",
				{0.75, 0.75, 1}
			)
		end

		player_record.sync = true
		player_record.dirty = true
	end
end

function inventory_sync.finish_download(player, finished_record)
	local status, result = pcall(inventory_sync.deserialize_player, player, finished_record)
	if not status then
		log("ERROR: Deserializing player " .. player.name .. " failed: " .. result)
		player.print("ERROR: Deserializing player data failed: " .. result)
	end
	local script_data = get_script_data()
	script_data.finished_downloads[player.name] = nil

	local player_record = script_data.players[player.name]
	player_record.dirty = true
	player_record.sync = true
	player_record.generation = finished_record.generation
end

-- Download inventory from controller
inventory_sync.events[defines.events.on_player_joined_game] = function(event)
	local player = assert(game.get_player(event.player_index))
	local script_data = get_script_data()

	-- It's possible Factorio doesn't invoke the on_player_created event when loading a save in single player
	if not script_data.players[player.name] then
		create_player(player, false)
	end

	-- Send acquire request even if an active download is currently in progress
	inventory_sync.acquire(player)

	-- Clear active upload if it exists
	script_data.active_uploads[player.name] = nil
end

inventory_sync.on_nth_tick = {}
inventory_sync.on_nth_tick[33] = function(event)
	-- Periodically check players currently in the process of syncing
	inventory_sync.check_players_waiting_for_acquire()
	inventory_sync.check_players_in_cutscene()
	inventory_sync.check_active_downloads()
	inventory_sync.check_active_uploads()
end

inventory_sync.events[defines.events.on_cutscene_waypoint_reached] = function(event)
	inventory_sync.check_players_in_cutscene()
end

inventory_sync.events[defines.events.on_cutscene_cancelled] = function(event)
	inventory_sync.check_players_in_cutscene()
end

-- Upload inventory when a player leaves the game. Triggers on restart after crash if player was online during crash.
inventory_sync.events[defines.events.on_pre_player_left_game] = function(event)
	-- for some reason, on_pre_player_left_game gets called before on_server_startup so script_data isn't ready yet
	if not compat.script_data.inventory_sync then
		log("ERROR: script data for inventory sync is not defined")
		return
	end

	local script_data = get_script_data()
	local player = assert(game.get_player(event.player_index))
	local player_record = script_data.players[player.name]

	-- If player has an active download release our acquisition but let the download continue
	if script_data.active_downloads[player.name] then
		clusterio_api.send_json("inventory_sync_release", { player_name = player.name })
		return
	end

	if not player_record.dirty or not player_record.sync then
		return
	end

	player_record.generation = player_record.generation + 1
	local status, result = pcall(inventory_sync.serialize_player, player, player_record)
	if not status then
		log("ERROR: Serializing player " .. player.name .. " failed: " .. result)
		player.print("ERROR: Serializing player data failed: " .. result)
		return
	end
	script_data.active_uploads[player.name] = {
		serialized = result,
		last_attempt = game.ticks_played,
		timeout = math.random(600, 1200), -- Start with 10-20 seconds for the timeout
	}

	clusterio_api.send_json("inventory_sync_upload", result)
end

-- Invoked by the instance code to confirm the controller has received the uploaded data
function inventory_sync.confirm_upload(player_name, generation)
	local player = game.get_player(player_name)
	if not player or player.connected then
		log("no player, or player connected")
		return
	end
	local script_data = get_script_data()
	local player_record = script_data.players[player.name]
	if not player_record or player_record.generation ~= generation then
		return
	end

	if not player_record.dirty then
		log("ERROR: Received upload confirmation for " .. player_name .. " without the dirty flag")
	end
	if not player_record.sync then
		log("ERROR: Received upload confirmation for " .. player_name .. " without the sync flag")
	end
	log("Confirmed upload of " .. player_name)
	script_data.active_uploads[player_name] = nil
	player_record.dirty = false
end

function inventory_sync.initiate_inventory_download(player, player_record, generation)
	player.print("Initiating inventory download...")
	local record = {
		started = game.ticks_played,
		last_active = game.ticks_played,
		generation = generation,
		data = ""
	}
	local script_data = get_script_data()
	script_data.active_downloads[player.name] = record

	clusterio_api.send_json("inventory_sync_download", {
		player_name = player.name
	})

	-- If this is a synced player turn them into a spectator while the
	-- player data is downloading
	if player_record.sync then
		-- Store original position to teleport back to
		record.surface = player.surface
		record.position = player.position
		if player.driving then
			record.vehicle = player.vehicle
		end

		local character = player.character
		player.set_controller({ type = defines.controllers.spectator })
		if character ~= nil then character.destroy() end

		-- Indicate this state shouldn't be persisted
		player_record.dirty = false
	end

	progress_dialog.display(player, 0, 1)
end

function inventory_sync.check_active_downloads()
	-- Used for detecting the inventory download failing, like when the map is saved and
	-- re-loaded in the middle of a download. The player will be returned to a playable state.
	local script_data = get_script_data()
	for player_name, record in pairs(script_data.active_downloads) do
		if record.last_active <= game.ticks_played - 600 then
			script_data.active_downloads[player_name] = nil
			local player = game.get_player(player_name)
			if player then
				log("ERROR: Inventory download failed for " .. player_name)

				if player.connected then
					-- Show GUI with option to retry or abort download
					dialog_failed_download.create(player, {
						player_name = player_name,
						status = "error",
						message = "Inventory download failed",
					})
				end
			end
		end
	end
end

function inventory_sync.check_active_uploads()
	local script_data = get_script_data()
	for player_name, record in pairs(script_data.active_uploads) do
		if record.last_attempt + record.timeout < game.ticks_played then
			local player_record = script_data.players[player_name]
			local player = game.get_player(player_name)
			if not player_record or not player then
				script_data.active_uploads[player_name] = nil
			else
				log("Retrying upload for " .. player_name)
				record.last_attempt = game.ticks_played
				record.timeout = math.random(record.timeout, record.timeout * 2)
				clusterio_api.send_json("inventory_sync_upload", record.serialized)
			end
		end
	end
end

return inventory_sync
