class_name NetworkSaleService
extends Node

const FishCatchType = preload("res://fish/fish_catch.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const FishDataType = preload("res://fish/fish_data.gd")
const FishPoolType = preload("res://fish/fish_pool.gd")
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
const FishSaleResultType = preload("res://economy/fish_sale_result.gd")

const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
const PELICAN_BUYER_ID: StringName = &"pelicans"
const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"

signal local_sale_pending(request_id: String)
signal local_sale_finished(
	request_id: String,
	accepted: bool,
	message: String,
	catch_ids: Array[StringName],
	payout: int,
)

var _session: NetworkSession
var _spawn_service: PlayerSpawnService
var _network_fishing: NetworkFishingService
var _shop_interaction: FishingShopInteraction
var _inventory: FishInventory
var _wallet: PlayerWallet
var _sale_service: FishSaleServiceType
var _save_manager: PlayerSaveManager
var _fish_catalog: FishPoolType
var _buyers: Dictionary[StringName, FishBuyerProfileType] = {}
var _request_ledgers: Dictionary[int, Dictionary] = {}
var _pending_by_peer: Dictionary[int, String] = {}
var _result_owners: Dictionary[String, int] = {}
var _acknowledged_results: Dictionary[String, bool] = {}
var _applied_results: Dictionary[String, bool] = {}
var _received_results: Dictionary[String, bool] = {}
var _pending_local_request_id: String = ""
var _pending_local_catch_ids: Array[StringName] = []
var _pending_local_buyer_id: StringName
var _reservations: PlayerAssetReservationService


func setup(
	session: NetworkSession,
	spawn_service: PlayerSpawnService,
	network_fishing: NetworkFishingService,
	shop_interaction: FishingShopInteraction,
	inventory: FishInventory,
	wallet: PlayerWallet,
	sale_service: FishSaleServiceType,
	save_manager: PlayerSaveManager,
	fish_catalog: FishPoolType,
	buyers: Array[FishBuyerProfileType],
	reservations: PlayerAssetReservationService,
) -> void:
	_session = session
	_spawn_service = spawn_service
	_network_fishing = network_fishing
	_shop_interaction = shop_interaction
	_inventory = inventory
	_wallet = wallet
	_sale_service = sale_service
	_save_manager = save_manager
	_fish_catalog = fish_catalog
	_buyers.clear()
	for buyer: FishBuyerProfileType in buyers:
		if buyer != null and buyer.is_valid():
			_buyers[buyer.id] = buyer
	_reservations = reservations
	if not _session.peer_removed.is_connected(_on_peer_removed):
		_session.peer_removed.connect(_on_peer_removed)
	if not _session.state_changed.is_connected(_on_session_state_changed):
		_session.state_changed.connect(_on_session_state_changed)


func is_local_sale_pending() -> bool:
	return not _pending_local_request_id.is_empty()


func can_request_sale(
	buyer_id: StringName = PELICAN_BUYER_ID,
) -> bool:
	var buyer: FishBuyerProfileType = _buyers.get(buyer_id)
	return (
		buyer != null
		and buyer.is_valid()
		and _session != null
		and _session.is_gameplay_session_active()
		and (
			_session.is_host()
			or _session.supports_server_capability(
				NetworkSaleProtocol.CAPABILITY
			)
		)
	)


func request_local_sale(
	catch_ids: Array[StringName],
	buyer_id: StringName = PELICAN_BUYER_ID,
) -> String:
	for catch_id: StringName in catch_ids:
		if _reservations != null and _reservations.is_fish_reserved(catch_id):
			local_sale_finished.emit(
				"", false, "Reserved in a letter.", _empty_catch_ids(), 0
			)
			return ""
	if is_local_sale_pending():
		local_sale_finished.emit(
			"", false, "Selling…", _empty_catch_ids(), 0
		)
		return ""
	if not can_request_sale(buyer_id):
		local_sale_finished.emit(
			"",
			false,
			"Selling is not supported by this server.",
			_empty_catch_ids(),
			0
		)
		return ""
	if catch_ids.is_empty() or _inventory == null:
		local_sale_finished.emit(
			"", false, "Sale could not be completed.", _empty_catch_ids(), 0
		)
		return ""
	var buyer: FishBuyerProfileType = _buyers.get(buyer_id)
	if buyer == null or not buyer.is_valid():
		local_sale_finished.emit(
			"", false, "The buyer is unavailable.", _empty_catch_ids(), 0
		)
		return ""
	var evidence: Array[Dictionary] = []
	for catch_id: StringName in catch_ids:
		var fish_catch: FishCatch = _inventory.get_catch_by_id(catch_id)
		if fish_catch == null:
			local_sale_finished.emit(
				"", false, "That catch is no longer available.", _empty_catch_ids(), 0
			)
			return ""
		evidence.append(fish_catch.to_network_dict())
	var request_id: String = _new_id("sale")
	var request: Dictionary = {
		"request_id": request_id,
		"session_id": _session.get_session_id(),
		"buyer_id": str(buyer.id),
		"catches": evidence,
	}
	_pending_local_request_id = request_id
	_pending_local_catch_ids = catch_ids.duplicate()
	_pending_local_buyer_id = buyer.id
	local_sale_pending.emit(request_id)
	if _session.is_host():
		_handle_sale_request(_session.get_local_peer_id(), request)
	else:
		submit_sale_request.rpc_id(1, request)
	return request_id


@rpc(
	"any_peer",
	"call_remote",
	"reliable",
	NetworkSaleProtocol.RELIABLE_CHANNEL
)
func submit_sale_request(data: Dictionary) -> void:
	var sender_id: int = multiplayer.get_remote_sender_id()
	if (
		_session == null
		or not _session.is_host()
		or not _session.is_authenticated_peer(sender_id)
	):
		return
	_handle_sale_request(sender_id, data)


func _handle_sale_request(peer_id: int, data: Dictionary) -> void:
	var validation_error: String = NetworkSaleProtocol.validate_request(data)
	var request_id: String = str(data.get("request_id", ""))
	var ledger: Dictionary = _request_ledgers.get(peer_id, {})
	if not request_id.is_empty() and ledger.has(request_id):
		_send_result(peer_id, ledger[request_id])
		return
	if not validation_error.is_empty():
		_record_and_send(peer_id, _rejected_result(
			request_id, validation_error
		))
		return
	if str(data["session_id"]) != _session.get_session_id():
		_record_and_send(peer_id, _rejected_result(
			request_id, "Sale could not be completed."
		))
		return
	if (
		_pending_by_peer.has(peer_id)
		and _pending_by_peer[peer_id] != request_id
	):
		_record_and_send(peer_id, _rejected_result(
			request_id, "A sale is already pending."
		))
		return
	var buyer_id := StringName(str(data["buyer_id"]))
	var buyer: FishBuyerProfileType = _buyers.get(buyer_id)
	if buyer == null or not buyer.is_valid():
		_record_and_send(peer_id, _rejected_result(
			request_id, "The buyer is unavailable."
		))
		return
	if (
		buyer_id == MAIN_SHOP_BUYER_ID
		and not _is_shop_available_for_peer(peer_id)
	):
		_record_and_send(peer_id, _rejected_result(
			request_id, "Move closer to the fishing shop."
		))
		return
	var result: Dictionary = _build_authoritative_result(
		peer_id, request_id, data["catches"], buyer
	)
	_pending_by_peer[peer_id] = request_id
	_record_and_send(peer_id, result)


func _build_authoritative_result(
	peer_id: int,
	request_id: String,
	evidence_values: Array,
	buyer: FishBuyerProfileType,
) -> Dictionary:
	if _fish_catalog == null or buyer == null:
		return _rejected_result(
			request_id, "The buyer is unavailable."
		)
	var catch_ids: Array[String] = []
	var base_value: int = 0
	var payout: int = 0
	for value: Variant in evidence_values:
		var evidence: Dictionary = value
		var fish_id := StringName(str(evidence.get("fish_id", "")))
		var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id)
		var decoded: FishCatch = FishCatchType.from_network_dict(
			evidence, fish
		)
		if decoded == null:
			return _rejected_result(
				request_id, "Sale could not be completed."
			)
		if (
			not str(decoded.catch_id).begins_with("%s:" % fish_id)
			or decoded.weight_lb < fish.get_minimum_weight()
			or decoded.weight_lb > fish.get_maximum_weight()
			or decoded.sale_value != FishQualityType.apply_sale_value(
				fish.get_sale_value_for_weight(decoded.weight_lb),
				decoded.quality,
			)
		):
			return _rejected_result(
				request_id, "Sale could not be completed."
			)
		if bool(evidence.get("is_favorited", false)):
			return _rejected_result(
				request_id, "Favorite catches cannot be sold."
			)
		var ordinary_value: int = fish.get_sale_value_for_weight(
			decoded.weight_lb
		)
		var offer: int = buyer.get_quality_offer(
			ordinary_value,
			decoded.quality,
		)
		if (
			offer < 0
			or base_value > 9223372036854775807 - decoded.sale_value
			or payout > 9223372036854775807 - offer
		):
			return _rejected_result(
				request_id, "Sale could not be completed."
			)
		base_value += decoded.sale_value
		payout += offer
		catch_ids.append(str(decoded.catch_id))
	return {
		"result_id": _new_id("sale_result"),
		"request_id": request_id,
		"session_id": _session.get_session_id(),
		"target_peer_id": peer_id,
		"buyer_id": str(buyer.id),
		"accepted": true,
		"catch_ids": catch_ids,
		"payout": payout,
		"base_value": base_value,
		"message": "Sale complete.",
	}


func _rejected_result(request_id: String, message: String) -> Dictionary:
	var safe_request_id: String = (
		request_id
		if (
			not request_id.is_empty()
			and request_id.length() <= NetworkSaleProtocol.MAX_ID_LENGTH
		)
		else "invalid"
	)
	return {
		"result_id": _new_id("sale_result"),
		"request_id": safe_request_id,
		"session_id": _session.get_session_id() if _session != null else "",
		"target_peer_id": 0,
		"accepted": false,
		"catch_ids": [],
		"payout": 0,
		"base_value": 0,
		"message": message.left(NetworkSaleProtocol.MAX_MESSAGE_LENGTH),
	}


func _record_and_send(peer_id: int, result: Dictionary) -> void:
	result["target_peer_id"] = peer_id
	var request_id: String = str(result["request_id"])
	var ledger: Dictionary = _request_ledgers.get(peer_id, {})
	ledger[request_id] = result.duplicate(true)
	while ledger.size() > MAX_LEDGER_ENTRIES_PER_PEER:
		ledger.erase(ledger.keys().front())
	_request_ledgers[peer_id] = ledger
	_result_owners[str(result["result_id"])] = peer_id
	_bound_dictionary(_result_owners)
	_send_result(peer_id, result)


func _send_result(peer_id: int, result: Dictionary) -> void:
	if peer_id == _session.get_local_peer_id():
		_apply_sale_result(result)
	else:
		receive_sale_result.rpc_id(peer_id, result)


@rpc(
	"authority",
	"call_remote",
	"reliable",
	NetworkSaleProtocol.RELIABLE_CHANNEL
)
func receive_sale_result(data: Dictionary) -> void:
	_apply_sale_result(data)


func _apply_sale_result(data: Dictionary) -> void:
	if (
		not NetworkSaleProtocol.validate_result(data)
		or _session == null
		or str(data["session_id"]) != _session.get_session_id()
		or int(data["target_peer_id"]) != _session.get_local_peer_id()
	):
		return
	var result_id: String = data["result_id"]
	if _received_results.has(result_id):
		_acknowledge_result(
			data, _applied_results.has(result_id), ""
		)
		return
	if str(data["request_id"]) != _pending_local_request_id:
		return
	var result_buyer_id := StringName(str(
		data.get("buyer_id", _pending_local_buyer_id)
	))
	if result_buyer_id != _pending_local_buyer_id:
		_fail_local_apply(data, "Sale could not be completed.")
		return
	if not bool(data["accepted"]):
		_received_results[result_id] = true
		_bound_dictionary(_received_results)
		_finish_local_sale(
			data["request_id"], false, str(data["message"]), [], 0
		)
		_acknowledge_result(data, false, str(data["message"]))
		return
	var catch_ids: Array[StringName] = []
	for value: Variant in data["catch_ids"]:
		if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
			_fail_local_apply(data, "Sale could not be completed.")
			return
		catch_ids.append(StringName(str(value)))
	if catch_ids != _pending_local_catch_ids:
		_fail_local_apply(data, "Sale could not be completed.")
		return
	for catch_id: StringName in catch_ids:
		if _reservations != null and _reservations.is_fish_reserved(catch_id):
			_fail_local_apply(data, "Reserved in a letter.")
			return
	var buyer: FishBuyerProfileType = _buyers.get(_pending_local_buyer_id)
	if buyer == null or not buyer.is_valid():
		_fail_local_apply(data, "The buyer is unavailable.")
		return
	var preview: FishSaleResultType = _sale_service.preview_batch(
		catch_ids, buyer
	)
	if not preview.is_success():
		var message: String = (
			"Favorite catches cannot be sold."
			if preview.status == FishSaleResultType.Status.FAVORITED
			else "That catch is no longer available."
		)
		_fail_local_apply(data, message)
		return
	if (
		preview.payout != int(data["payout"])
		or preview.base_value != int(data["base_value"])
	):
		_fail_local_apply(data, "Sale could not be completed.")
		return
	var inventory_snapshot: Array[FishCatch] = _inventory.get_all_catches()
	var sequence_snapshot: int = _inventory.get_next_catch_sequence()
	var wallet_snapshot: int = _wallet.get_balance()
	var local_result: FishSaleResultType = _sale_service.sell_batch(
		catch_ids, buyer
	)
	if not local_result.is_success() or not _save_manager.save_if_dirty():
		_inventory.replace_all_catches(
			inventory_snapshot, sequence_snapshot
		)
		_wallet.restore_balance(wallet_snapshot)
		_save_manager.save_if_dirty()
		_fail_local_apply(data, "Sale could not be completed.")
		return
	_applied_results[result_id] = true
	_bound_dictionary(_applied_results)
	_received_results[result_id] = true
	_bound_dictionary(_received_results)
	_finish_local_sale(
		data["request_id"],
		true,
		"Sale complete.",
		catch_ids,
		int(data["payout"])
	)
	_acknowledge_result(data, true, "")


func _fail_local_apply(data: Dictionary, message: String) -> void:
	_finish_local_sale(
		data["request_id"], false, message, _empty_catch_ids(), 0
	)
	_acknowledge_result(data, false, message)


func _finish_local_sale(
	request_id: String,
	accepted: bool,
	message: String,
	catch_ids: Array[StringName],
	payout: int,
) -> void:
	_pending_local_request_id = ""
	_pending_local_catch_ids.clear()
	_pending_local_buyer_id = StringName()
	local_sale_finished.emit(
		request_id, accepted, message, catch_ids, payout
	)


func _acknowledge_result(
	data: Dictionary,
	applied: bool,
	message: String,
) -> void:
	if _session.is_host():
		_handle_acknowledgement(
			_session.get_local_peer_id(),
			str(data["result_id"]),
			str(data["request_id"]),
			applied,
			message
		)
	else:
		acknowledge_sale_result.rpc_id(
			1,
			str(data["result_id"]),
			str(data["request_id"]),
			applied,
			message.left(NetworkSaleProtocol.MAX_MESSAGE_LENGTH)
		)


@rpc(
	"any_peer",
	"call_remote",
	"reliable",
	NetworkSaleProtocol.RELIABLE_CHANNEL
)
func acknowledge_sale_result(
	result_id: String,
	request_id: String,
	applied: bool,
	message: String,
) -> void:
	var sender_id: int = multiplayer.get_remote_sender_id()
	if (
		_session == null
		or not _session.is_host()
		or not _session.is_authenticated_peer(sender_id)
	):
		return
	_handle_acknowledgement(
		sender_id, result_id, request_id, applied, message
	)


func _handle_acknowledgement(
	peer_id: int,
	result_id: String,
	request_id: String,
	_applied: bool,
	_message: String,
) -> void:
	if (
		result_id.is_empty()
		or result_id.length() > NetworkSaleProtocol.MAX_ID_LENGTH
		or request_id.is_empty()
		or request_id.length() > NetworkSaleProtocol.MAX_ID_LENGTH
		or _message.length() > NetworkSaleProtocol.MAX_MESSAGE_LENGTH
		or _result_owners.get(result_id, 0) != peer_id
	):
		return
	_acknowledged_results[result_id] = true
	_bound_dictionary(_acknowledged_results)
	if _pending_by_peer.get(peer_id, "") == request_id:
		_pending_by_peer.erase(peer_id)


func _on_peer_removed(peer_id: int) -> void:
	_request_ledgers.erase(peer_id)
	_pending_by_peer.erase(peer_id)
	for result_id: String in _result_owners.keys():
		if _result_owners[result_id] == peer_id:
			_result_owners.erase(result_id)
			_acknowledged_results.erase(result_id)


func _is_shop_available_for_peer(peer_id: int) -> bool:
	if (
		_session == null
		or not _session.is_host()
		or not _session.is_gameplay_session_active()
		or _spawn_service == null
		or _shop_interaction == null
		or not is_instance_valid(_shop_interaction)
	):
		return false
	var avatar: Player = _spawn_service.get_avatar(peer_id)
	return (
		avatar != null
		and not avatar.is_water_recovery_active()
		and (
			_network_fishing == null
			or not _network_fishing.has_peer_attempt(peer_id)
		)
		and _shop_interaction.is_avatar_in_range(avatar)
	)


func _on_session_state_changed(state: NetworkSession.State) -> void:
	if state in [
		NetworkSession.State.INACTIVE,
		NetworkSession.State.DISCONNECTING,
		NetworkSession.State.CONNECTION_FAILED,
		NetworkSession.State.SERVER_LOST,
	]:
		var had_pending: bool = is_local_sale_pending()
		_clear_session_state()
		if had_pending:
			local_sale_finished.emit(
				"", false, "Connection lost.", _empty_catch_ids(), 0
			)


func _empty_catch_ids() -> Array[StringName]:
	return []


func _clear_session_state() -> void:
	_request_ledgers.clear()
	_pending_by_peer.clear()
	_result_owners.clear()
	_acknowledged_results.clear()
	_applied_results.clear()
	_received_results.clear()
	_pending_local_request_id = ""
	_pending_local_catch_ids.clear()
	_pending_local_buyer_id = StringName()


func _bound_dictionary(values: Dictionary) -> void:
	while values.size() > MAX_LEDGER_ENTRIES_PER_PEER:
		values.erase(values.keys().front())


func _new_id(prefix: String) -> String:
	return "%s:%s" % [
		prefix,
		Crypto.new().generate_random_bytes(16).hex_encode(),
	]
