extends Node3D

const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters"
const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations"
const INVENTORY_API_URL := "https://pinv.ranaze.com/api/inventory"
const MAIL_API_URL := "https://pmail.ranaze.com/api/mail"
const START_SCREEN_SCENE := "res://scenes/UI/start_screen.tscn"
const SETTINGS_SCENE := "res://scenes/UI/Settings.tscn"
const CHARACTER_SLOT_COUNT := 6
const VISIBLE_CHARACTERS_REFRESH_INTERVAL := 5.0
const HEARTBEAT_INTERVAL := 10.0

@export var tile_size := 8.0
@export var block_height := 1.0
@export var elevation_step_height := 0.5
@export var tile_wall_thickness := 0.08
@export_range(1, 8, 1) var tile_radius := 3
@export var tracked_node_path: NodePath
@export var player_spawn_height := 2.0
@export var border_color: Color = Color(0.05, 0.05, 0.05, 1.0)
@export var border_height_bias := 0.005
@export var show_tile_labels := true
@export var tile_label_height := 0.01
@export var tile_label_color: Color = Color(1, 1, 1, 1)

@onready var _block: MeshInstance3D = $TerrainBlock
@onready var _player: RigidBody3D = $Player
@onready var _camera: Camera3D = $Player/Camera3D
@onready var _player_visual: Node3D = $Player/TestCharAnimated
@onready var _pause_menu: CanvasLayer = $PauseMenu
@onready var _inventory_menu: CanvasLayer = $InventoryMenu
@onready var _inventory_location_label: Label = $InventoryMenu/MarginContainer/Panel/VBoxContainer/CurrentLocationLabel
@onready var _character_items_list: ItemList = $InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/CharacterPanel/VBoxContainer/CharacterItems
@onready var _ground_items_list: ItemList = $InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/GroundPanel/VBoxContainer/GroundItems
@onready var _target_slot_spin_box: SpinBox = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ControlsRow/TargetSlotSpinBox
@onready var _quantity_spin_box: SpinBox = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ControlsRow/QuantitySpinBox
@onready var _inventory_status_label: Label = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/StatusLabel
@onready var _mail_menu: CanvasLayer = $MailMenu
@onready var _mail_location_label: Label = $MailMenu/MarginContainer/Panel/VBoxContainer/MailboxLocationLabel
@onready var _mail_inbox_items: ItemList = $MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel/VBoxContainer/InboxItems
@onready var _mail_sent_items: ItemList = $MailMenu/MarginContainer/Panel/VBoxContainer/Columns/SentPanel/VBoxContainer/SentItems
@onready var _mail_selected_label: Label = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/SelectedMailLabel
@onready var _mail_selected_body: TextEdit = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/SelectedMailBody
@onready var _mail_recipient_line_edit: LineEdit = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/RecipientLineEdit
@onready var _mail_subject_line_edit: LineEdit = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/SubjectLineEdit
@onready var _mail_compose_body: TextEdit = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/ComposeBody
@onready var _mail_status_label: Label = $MailMenu/MarginContainer/Panel/VBoxContainer/DetailsComposePanel/VBoxContainer/StatusLabel

var _center_coord := Vector2i.ZERO
var _tiles_root: Node3D
var _remote_players_root: Node3D
var _tracked_node: Node3D
var _tile_nodes: Dictionary = {}
var _camera_start_offset := Vector3(0.0, 6.0, 10.0)
var _border_material: StandardMaterial3D
var _biome_materials: Dictionary = {}
var _known_locations: Dictionary = {}
var _locations_loaded := false
var _character_id := ""
var _persisted_coord := Vector2i.ZERO
var _coord_sync_in_flight := false
var _queued_coord_sync: Variant = null
var _locations_refresh_in_flight := false
var _queued_locations_refresh := false
var _interact_in_flight := false
var _inventory_request_in_flight := false
var _character_inventory_items: Array = []
var _selected_character_item_id := ""
var _selected_ground_item_id := ""
var _visible_characters_in_flight := false
var _heartbeat_in_flight := false
var _visible_character_refresh_elapsed := 0.0
var _heartbeat_elapsed := 0.0
var _remote_character_nodes: Dictionary = {}
var _mail_inbox: Array = []
var _mail_sent: Array = []
var _mail_request_in_flight := false
var _selected_inbox_mail_id := ""
var _selected_sent_mail_id := ""


func _ready() -> void:
	_tiles_root = Node3D.new()
	_tiles_root.name = "GeneratedTiles"
	add_child(_tiles_root)
	_remote_players_root = Node3D.new()
	_remote_players_root.name = "RemotePlayers"
	add_child(_remote_players_root)

	if _camera:
		_camera_start_offset = _camera.position

	_tracked_node = get_node_or_null(tracked_node_path) as Node3D
	if _tracked_node == null:
		_tracked_node = _player

	var start_coord := SelectedCharacter.get_coord()
	_center_coord = Vector2i(roundi(start_coord.x), roundi(start_coord.y))
	_persisted_coord = _center_coord
	_character_id = String(SelectedCharacter.character.get("id", SelectedCharacter.character.get("Id", ""))).strip_edges()

	_block.visible = false
	_deactivate_player_for_load()
	await _load_existing_locations()
	_refresh_visible_characters()
	_ensure_selected_location_exists(_center_coord)
	_rebuild_tiles(_center_coord)
	_move_player_to_coord(_center_coord)
	_activate_player_after_load()


func _process(_delta: float) -> void:
	if not _locations_loaded:
		return
	_visible_character_refresh_elapsed += _delta
	_heartbeat_elapsed += _delta
	if _visible_character_refresh_elapsed >= VISIBLE_CHARACTERS_REFRESH_INTERVAL:
		_visible_character_refresh_elapsed = 0.0
		_refresh_visible_characters()
	if _heartbeat_elapsed >= HEARTBEAT_INTERVAL:
		_heartbeat_elapsed = 0.0
		_send_presence_heartbeat()
	if _inventory_menu.visible or _mail_menu.visible:
		return
	var target_world_pos := _get_stream_position()
	var target_coord := _world_to_coord(target_world_pos)
	if target_coord == _center_coord:
		return
	_center_coord = target_coord
	_queue_coord_sync(_center_coord)
	_queue_locations_refresh()


func _input(event: InputEvent) -> void:
	if event.is_action_pressed("player_phone"):
		if get_tree().paused or _mail_menu.visible:
			return
		_toggle_inventory_menu()
		get_viewport().set_input_as_handled()


func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_cancel") and _mail_menu.visible:
		_close_mail_menu()
		get_viewport().set_input_as_handled()
		return
	if event.is_action_pressed("ui_cancel") and _inventory_menu.visible:
		_close_inventory_menu()
		get_viewport().set_input_as_handled()
		return
	if event.is_action_pressed("ui_cancel"):
		_toggle_pause_menu()
		get_viewport().set_input_as_handled()
		return
	if event.is_action_pressed("interact"):
		if get_tree().paused or _inventory_menu.visible:
			return
		_try_interact_current_tile()


func _get_stream_position() -> Vector3:
	if _tracked_node:
		return _tracked_node.global_position
	return _coord_to_world(_center_coord)


func _world_to_coord(world_pos: Vector3) -> Vector2i:
	return Vector2i(
		roundi(world_pos.x / tile_size),
		roundi(world_pos.z / tile_size)
	)


func _coord_to_world(coord: Vector2i) -> Vector3:
	return Vector3(coord.x * tile_size, _get_tile_center_y(coord), coord.y * tile_size)


func _move_player_to_coord(coord: Vector2i) -> void:
	if _player == null:
		return
	_player.global_position = Vector3(coord.x * tile_size, _get_tile_surface_y(coord) + player_spawn_height, coord.y * tile_size)
	_player.linear_velocity = Vector3.ZERO
	_player.angular_velocity = Vector3.ZERO


func _deactivate_player_for_load() -> void:
	if _player == null:
		return
	_player.freeze = true
	_player.sleeping = true
	_player.linear_velocity = Vector3.ZERO
	_player.angular_velocity = Vector3.ZERO
	if _player_visual:
		_player_visual.visible = false


func _activate_player_after_load() -> void:
	if _player == null:
		return
	_player.linear_velocity = Vector3.ZERO
	_player.angular_velocity = Vector3.ZERO
	_player.sleeping = false
	_player.freeze = false
	if _player_visual:
		_player_visual.visible = true


func _toggle_pause_menu() -> void:
	if _pause_menu == null:
		return
	if get_tree().paused:
		_resume_game()
	else:
		_pause_game()


func _pause_game() -> void:
	if _inventory_menu.visible:
		_close_inventory_menu()
	if _mail_menu.visible:
		_close_mail_menu()
	get_tree().paused = true
	_pause_menu.visible = true
	Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)


func _resume_game() -> void:
	get_tree().paused = false
	_pause_menu.visible = false
	Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED if not _inventory_menu.visible else Input.MOUSE_MODE_VISIBLE)


func _toggle_inventory_menu() -> void:
	if _mail_menu.visible:
		return
	if _inventory_menu.visible:
		_close_inventory_menu()
		return
	_open_inventory_menu()


func _open_inventory_menu() -> void:
	if _character_id.is_empty():
		return
	_inventory_menu.visible = true
	_inventory_status_label.text = "Loading inventory..."
	_set_player_menu_lock(true)
	_update_inventory_location_label()
	_refresh_inventory_menu_data()


func _close_inventory_menu() -> void:
	_inventory_menu.visible = false
	_inventory_status_label.text = ""
	_selected_character_item_id = ""
	_selected_ground_item_id = ""
	_character_items_list.deselect_all()
	_ground_items_list.deselect_all()
	_set_player_menu_lock(false)


func _open_mail_menu() -> void:
	_mail_menu.visible = true
	_mail_status_label.text = "Loading mailbox..."
	_selected_inbox_mail_id = ""
	_selected_sent_mail_id = ""
	_set_player_menu_lock(true)
	_update_mail_location_label()
	_refresh_mailbox()


func _close_mail_menu() -> void:
	_mail_menu.visible = false
	_mail_status_label.text = ""
	_set_player_menu_lock(false)


func _update_mail_location_label() -> void:
	var location_data := _get_location_data(_center_coord)
	var location_name := String(location_data.get("name", "Mailbox")).strip_edges()
	_mail_location_label.text = "%s (%d,%d)" % [location_name, _center_coord.x, _center_coord.y]


func _set_player_menu_lock(locked: bool) -> void:
	if _player == null:
		return
	if locked:
		_player.freeze = true
		_player.sleeping = true
		_player.linear_velocity = Vector3.ZERO
		_player.angular_velocity = Vector3.ZERO
		Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
		return
	_player.sleeping = false
	_player.freeze = false
	if not get_tree().paused:
		Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)


func _on_pause_continue_pressed() -> void:
	_resume_game()


func _on_pause_settings_pressed() -> void:
	_resume_game()
	Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
	get_tree().change_scene_to_file(SETTINGS_SCENE)


func _on_pause_main_menu_pressed() -> void:
	_resume_game()
	Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
	get_tree().change_scene_to_file(START_SCREEN_SCENE)


func _rebuild_tiles(center: Vector2i) -> void:
	var wanted_keys: Dictionary = {}
	for x in range(center.x - tile_radius, center.x + tile_radius + 1):
		for y in range(center.y - tile_radius, center.y + tile_radius + 1):
			var coord := Vector2i(x, y)
			if not _known_locations.has(coord):
				continue
			wanted_keys[coord] = true
			var location_data := _known_locations[coord] as Dictionary
			if _tile_nodes.has(coord):
				_update_tile(coord, location_data)
				continue
			_spawn_tile(coord, location_data)

	for key in _tile_nodes.keys():
		if wanted_keys.has(key):
			continue
		var tile_node := _tile_nodes[key] as Node3D
		if tile_node:
			tile_node.queue_free()
		_tile_nodes.erase(key)


func _spawn_tile(coord: Vector2i, location_data: Dictionary) -> void:
	var tile_root := Node3D.new()
	tile_root.name = "Tile_%d_%d" % [coord.x, coord.y]
	tile_root.position = _coord_to_world(coord)
	_tiles_root.add_child(tile_root)

	var tile_body := StaticBody3D.new()
	tile_body.name = "TileBody"
	tile_body.scale = Vector3(tile_size, block_height, tile_size)
	tile_root.add_child(tile_body)

	var collision_shape := CollisionShape3D.new()
	collision_shape.name = "CollisionShape3D"
	collision_shape.shape = BoxShape3D.new()
	tile_body.add_child(collision_shape)

	var tile := _block.duplicate() as MeshInstance3D
	tile.name = "TileMesh"
	tile.visible = true
	var biome_key := String(location_data.get("biomeKey", "plains")).strip_edges()
	var biome_material := _get_biome_material(tile, biome_key)
	if biome_material:
		tile.material_override = biome_material
	tile_body.add_child(tile)
	_update_tile_edge_walls(tile_root, coord, biome_material)

	tile.add_child(_create_tile_border())
	if show_tile_labels:
		tile_root.add_child(_create_tile_label(String(location_data.get("name", ""))))
	_update_tile_object(tile_root, location_data)
	_update_tile_inventory(tile_root, location_data)

	_tile_nodes[coord] = tile_root


func _update_tile(coord: Vector2i, location_data: Dictionary) -> void:
	var tile_root := _tile_nodes.get(coord) as Node3D
	if tile_root == null:
		return
	tile_root.position = _coord_to_world(coord)
	var tile_mesh := tile_root.get_node_or_null("TileBody/TileMesh") as MeshInstance3D
	var biome_material: Material = null
	if tile_mesh:
		biome_material = tile_mesh.material_override
	_update_tile_edge_walls(tile_root, coord, biome_material)

	if show_tile_labels:
		var label := tile_root.get_node_or_null("LocationNameLabel") as Label3D
		if label:
			label.text = String(location_data.get("name", ""))

	_update_tile_object(tile_root, location_data)
	_update_tile_inventory(tile_root, location_data)


func _update_tile_object(tile_root: Node3D, location_data: Dictionary) -> void:
	var object_data_variant: Variant = location_data.get("locationObject", {})
	if typeof(object_data_variant) != TYPE_DICTIONARY:
		var existing_missing := tile_root.get_node_or_null("LocationObject")
		if existing_missing:
			existing_missing.queue_free()
		return

	var object_data := object_data_variant as Dictionary
	if object_data.is_empty():
		var existing_empty := tile_root.get_node_or_null("LocationObject")
		if existing_empty:
			existing_empty.queue_free()
		return

	var object_root := tile_root.get_node_or_null("LocationObject") as Node3D
	if object_root == null:
		object_root = Node3D.new()
		object_root.name = "LocationObject"
		object_root.position = Vector3(0.0, (block_height * 0.5) + 0.6, 0.0)
		tile_root.add_child(object_root)

	var object_mesh := object_root.get_node_or_null("ObjectMesh") as MeshInstance3D
	if object_mesh == null:
		object_mesh = MeshInstance3D.new()
		object_mesh.name = "ObjectMesh"
		object_mesh.mesh = SphereMesh.new()
		object_mesh.scale = Vector3(0.6, 0.4, 0.6)
		object_root.add_child(object_mesh)
	object_mesh.material_override = _create_object_material(String(object_data.get("objectKey", "")))

	var object_label := object_root.get_node_or_null("ObjectLabel") as Label3D
	if object_label == null:
		object_label = Label3D.new()
		object_label.name = "ObjectLabel"
		object_label.position = Vector3(0.0, 0.6, 0.0)
		object_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
		object_label.pixel_size = 0.01
		object_label.outline_size = 10
		object_root.add_child(object_label)
	object_label.text = _build_object_label(object_data)


func _update_tile_inventory(tile_root: Node3D, location_data: Dictionary) -> void:
	var floor_items: Array = location_data.get("floorItems", [])
	var existing_label := tile_root.get_node_or_null("FloorInventoryLabel") as Label3D
	if floor_items.is_empty():
		if existing_label:
			existing_label.queue_free()
		return

	if existing_label == null:
		existing_label = Label3D.new()
		existing_label.name = "FloorInventoryLabel"
		existing_label.position = Vector3(0.0, (block_height * 0.5) + 1.7, -0.9)
		existing_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
		existing_label.pixel_size = 0.01
		existing_label.outline_size = 10
		existing_label.modulate = Color(1.0, 0.95, 0.75, 1.0)
		tile_root.add_child(existing_label)

	existing_label.text = _build_floor_inventory_label(floor_items)


func _create_tile_border() -> MeshInstance3D:
	var top_y := 0.5 + border_height_bias
	var corners := [
		Vector3(-0.5, top_y, -0.5),
		Vector3(0.5, top_y, -0.5),
		Vector3(0.5, top_y, 0.5),
		Vector3(-0.5, top_y, 0.5),
	]

	var border_mesh := ImmediateMesh.new()
	border_mesh.surface_begin(Mesh.PRIMITIVE_LINES, _get_border_material())
	for idx in range(corners.size()):
		var current: Vector3 = corners[idx]
		var next: Vector3 = corners[(idx + 1) % corners.size()]
		border_mesh.surface_add_vertex(current)
		border_mesh.surface_add_vertex(next)
	border_mesh.surface_end()

	var border := MeshInstance3D.new()
	border.name = "TileBorder"
	border.mesh = border_mesh
	return border


func _update_tile_edge_walls(tile_root: Node3D, coord: Vector2i, biome_material: Material) -> void:
	var walls_root := tile_root.get_node_or_null("TileEdgeWalls") as Node3D
	if walls_root == null:
		walls_root = Node3D.new()
		walls_root.name = "TileEdgeWalls"
		tile_root.add_child(walls_root)

	for child in walls_root.get_children():
		child.queue_free()

	var current_bottom := _get_tile_bottom_y(coord)
	var directions := [
		{"name": "NorthWall", "offset": Vector2i(0, -1), "position": Vector3(0.0, 0.0, -((tile_size * 0.5) - (tile_wall_thickness * 0.5))), "scale": Vector3(tile_size, 1.0, tile_wall_thickness)},
		{"name": "SouthWall", "offset": Vector2i(0, 1), "position": Vector3(0.0, 0.0, (tile_size * 0.5) - (tile_wall_thickness * 0.5)), "scale": Vector3(tile_size, 1.0, tile_wall_thickness)},
		{"name": "WestWall", "offset": Vector2i(-1, 0), "position": Vector3(-((tile_size * 0.5) - (tile_wall_thickness * 0.5)), 0.0, 0.0), "scale": Vector3(tile_wall_thickness, 1.0, tile_size)},
		{"name": "EastWall", "offset": Vector2i(1, 0), "position": Vector3((tile_size * 0.5) - (tile_wall_thickness * 0.5), 0.0, 0.0), "scale": Vector3(tile_wall_thickness, 1.0, tile_size)}
	]

	for entry_variant in directions:
		var entry := entry_variant as Dictionary
		var neighbor_coord := coord + (entry["offset"] as Vector2i)
		var neighbor_top := _get_neighbor_top_y(neighbor_coord)
		if neighbor_top >= current_bottom:
			continue

		var wall_height := current_bottom - neighbor_top
		var wall := MeshInstance3D.new()
		wall.name = String(entry["name"])
		var wall_mesh := BoxMesh.new()
		wall_mesh.size = Vector3(1, 1, 1)
		wall.mesh = wall_mesh
		wall.scale = (entry["scale"] as Vector3) * Vector3(1.0, wall_height, 1.0)
		var wall_position := entry["position"] as Vector3
		wall.position = Vector3(wall_position.x, -(block_height * 0.5) - (wall_height * 0.5), wall_position.z)
		if biome_material:
			wall.material_override = biome_material
		walls_root.add_child(wall)


func _get_border_material() -> StandardMaterial3D:
	if _border_material:
		return _border_material

	_border_material = StandardMaterial3D.new()
	_border_material.albedo_color = border_color
	_border_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	_border_material.disable_receive_shadows = true
	_border_material.no_depth_test = true
	return _border_material


func _create_tile_label(location_name: String) -> Label3D:
	var label := Label3D.new()
	label.name = "LocationNameLabel"
	label.text = location_name
	label.position = Vector3(0.0, (block_height * 0.5) + border_height_bias + tile_label_height, 0.0)
	label.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
	label.billboard = BaseMaterial3D.BILLBOARD_DISABLED
	label.modulate = tile_label_color
	label.pixel_size = 0.01
	label.outline_size = 12
	label.no_depth_test = false
	return label


func _create_object_material(object_key: String) -> StandardMaterial3D:
	var material := StandardMaterial3D.new()
	material.roughness = 1.0
	material.metallic = 0.0

	if object_key.contains("grass"):
		material.albedo_color = Color(0.28, 0.68, 0.25, 1.0)
	elif object_key.contains("wood"):
		material.albedo_color = Color(0.54, 0.36, 0.18, 1.0)
	elif object_key.contains("stone"):
		material.albedo_color = Color(0.55, 0.57, 0.6, 1.0)
	elif object_key.contains("mailbox"):
		material.albedo_color = Color(0.22, 0.44, 0.78, 1.0)
	else:
		material.albedo_color = Color(0.85, 0.75, 0.3, 1.0)

	return material


func _build_object_label(object_data: Dictionary) -> String:
	var object_name := String(object_data.get("name", "")).strip_edges()
	var state_variant: Variant = object_data.get("state", {})
	var remaining_quantity := 0
	if typeof(state_variant) == TYPE_DICTIONARY:
		remaining_quantity = int((state_variant as Dictionary).get("remainingQuantity", 0))
	if object_name.is_empty():
		object_name = "Object"
	return "%s x%d" % [object_name, remaining_quantity]


func _build_floor_inventory_label(floor_items: Array) -> String:
	var parts: Array[String] = []
	for entry in floor_items:
		if typeof(entry) != TYPE_DICTIONARY:
			continue
		var item := entry as Dictionary
		parts.append("%s x%d" % [
			String(item.get("itemKey", "")).strip_edges(),
			int(item.get("quantity", 0))
		])
		if parts.size() >= 3:
			break

	if parts.is_empty():
		return ""

	var label := "Floor: %s" % ", ".join(parts)
	if floor_items.size() > parts.size():
		label += " ..."
	return label


func _get_tile_elevation(coord: Vector2i) -> int:
	var location_data := _get_location_data(coord)
	return int(location_data.get("elevation", 0))


func _get_tile_center_y(coord: Vector2i) -> float:
	return (_get_tile_elevation(coord) * elevation_step_height) + (block_height * 0.5)


func _get_tile_bottom_y(coord: Vector2i) -> float:
	return _get_tile_elevation(coord) * elevation_step_height


func _get_tile_surface_y(coord: Vector2i) -> float:
	return (_get_tile_elevation(coord) * elevation_step_height) + block_height


func _get_neighbor_top_y(coord: Vector2i) -> float:
	if _known_locations.has(coord):
		return _get_tile_surface_y(coord)
	return _get_tile_bottom_y(coord)


func _update_inventory_location_label() -> void:
	if _inventory_location_label == null:
		return
	var location_data := _get_location_data(_center_coord)
	var location_name := String(location_data.get("name", "Unknown Location")).strip_edges()
	_inventory_location_label.text = "%s (%d,%d)" % [location_name, _center_coord.x, _center_coord.y]


func _refresh_inventory_menu_data() -> void:
	if _inventory_request_in_flight:
		return
	_refresh_inventory_menu_data_async()


func _refresh_inventory_menu_data_async() -> void:
	_inventory_request_in_flight = true
	_update_inventory_location_label()
	_character_inventory_items = await _fetch_character_inventory()
	var location_id := _get_current_location_id()
	if not location_id.is_empty():
		await _refresh_location_inventory(location_id)
	_render_inventory_menu()
	_inventory_request_in_flight = false


func _render_inventory_menu() -> void:
	var current_character_selection := _selected_character_item_id
	var current_ground_selection := _selected_ground_item_id

	_character_items_list.clear()
	var slot_map := {}
	for item_variant in _character_inventory_items:
		if typeof(item_variant) != TYPE_DICTIONARY:
			continue
		var item := item_variant as Dictionary
		var slot_value: Variant = item.get("slot", null)
		if typeof(slot_value) == TYPE_NIL:
			continue
		slot_map[int(slot_value)] = item

	for slot_index in range(CHARACTER_SLOT_COUNT):
		var text := "Slot %d: (empty)" % slot_index
		var metadata: Dictionary = {}
		if slot_map.has(slot_index):
			var slot_item := slot_map[slot_index] as Dictionary
			text = "Slot %d: %s x%d" % [
				slot_index,
				String(slot_item.get("itemKey", "")).strip_edges(),
				int(slot_item.get("quantity", 0))
			]
			metadata = slot_item
		_character_items_list.add_item(text)
		_character_items_list.set_item_metadata(slot_index, metadata)
		if not current_character_selection.is_empty() and String(metadata.get("itemId", metadata.get("id", ""))).strip_edges() == current_character_selection:
			_character_items_list.select(slot_index)

	_ground_items_list.clear()
	var floor_items := _get_current_floor_items()
	for index in range(floor_items.size()):
		var floor_item := floor_items[index] as Dictionary
		var floor_text := "%s x%d" % [
			String(floor_item.get("itemKey", "")).strip_edges(),
			int(floor_item.get("quantity", 0))
		]
		_ground_items_list.add_item(floor_text)
		_ground_items_list.set_item_metadata(index, floor_item)
		if not current_ground_selection.is_empty() and String(floor_item.get("itemId", floor_item.get("id", ""))).strip_edges() == current_ground_selection:
			_ground_items_list.select(index)

	_update_inventory_controls()


func _update_inventory_controls() -> void:
	var selected_item := _get_selected_inventory_item()
	var max_quantity := 1
	if not selected_item.is_empty():
		max_quantity = max(1, int(selected_item.get("quantity", 1)))
		if _selected_ground_item_id == String(selected_item.get("itemId", selected_item.get("id", ""))).strip_edges():
			_target_slot_spin_box.value = float(_default_slot_for_item(selected_item))
	_quantity_spin_box.max_value = float(max_quantity)
	if int(_quantity_spin_box.value) > max_quantity:
		_quantity_spin_box.value = float(max_quantity)
	if int(_quantity_spin_box.value) < 1:
		_quantity_spin_box.value = 1.0


func _get_selected_inventory_item() -> Dictionary:
	if not _selected_character_item_id.is_empty():
		return _find_item_by_id(_character_inventory_items, _selected_character_item_id)
	if not _selected_ground_item_id.is_empty():
		return _find_item_by_id(_get_current_floor_items(), _selected_ground_item_id)
	return {}


func _find_item_by_id(items: Array, item_id: String) -> Dictionary:
	for item_variant in items:
		if typeof(item_variant) != TYPE_DICTIONARY:
			continue
		var item := item_variant as Dictionary
		if String(item.get("itemId", item.get("id", ""))).strip_edges() == item_id:
			return item
	return {}


func _get_current_location_id() -> String:
	var location_data := _get_location_data(_center_coord)
	return String(location_data.get("id", "")).strip_edges()


func _get_current_floor_items() -> Array:
	var location_data := _get_location_data(_center_coord)
	return location_data.get("floorItems", [])


func _default_slot_for_item(item: Dictionary) -> int:
	var item_key := String(item.get("itemKey", "")).strip_edges()
	for existing_variant in _character_inventory_items:
		if typeof(existing_variant) != TYPE_DICTIONARY:
			continue
		var existing := existing_variant as Dictionary
		if String(existing.get("itemKey", "")).strip_edges() != item_key:
			continue
		return int(existing.get("slot", 0))
	return _first_open_character_slot()


func _first_open_character_slot() -> int:
	var used_slots := {}
	for item_variant in _character_inventory_items:
		if typeof(item_variant) != TYPE_DICTIONARY:
			continue
		var item := item_variant as Dictionary
		var slot_value: Variant = item.get("slot", null)
		if typeof(slot_value) == TYPE_NIL:
			continue
		used_slots[int(slot_value)] = true
	for slot_index in range(CHARACTER_SLOT_COUNT):
		if not used_slots.has(slot_index):
			return slot_index
	return 0


func _on_character_items_selected(index: int) -> void:
	var metadata: Variant = _character_items_list.get_item_metadata(index)
	_selected_ground_item_id = ""
	_ground_items_list.deselect_all()
	if typeof(metadata) != TYPE_DICTIONARY or (metadata as Dictionary).is_empty():
		_selected_character_item_id = ""
	else:
		var item := metadata as Dictionary
		_selected_character_item_id = String(item.get("itemId", item.get("id", ""))).strip_edges()
		_target_slot_spin_box.value = float(int(item.get("slot", 0)))
	_update_inventory_controls()


func _on_ground_items_selected(index: int) -> void:
	var metadata: Variant = _ground_items_list.get_item_metadata(index)
	_selected_character_item_id = ""
	_character_items_list.deselect_all()
	if typeof(metadata) != TYPE_DICTIONARY or (metadata as Dictionary).is_empty():
		_selected_ground_item_id = ""
	else:
		var item := metadata as Dictionary
		_selected_ground_item_id = String(item.get("itemId", item.get("id", ""))).strip_edges()
		_target_slot_spin_box.value = float(_default_slot_for_item(item))
	_update_inventory_controls()


func _on_inventory_move_pressed() -> void:
	if _selected_character_item_id.is_empty():
		_inventory_status_label.text = "Select a character item first."
		return
	var to_slot := int(_target_slot_spin_box.value)
	var quantity := int(_quantity_spin_box.value)
	_move_character_item_async(_selected_character_item_id, to_slot, quantity)


func _on_inventory_drop_pressed() -> void:
	if _selected_character_item_id.is_empty():
		_inventory_status_label.text = "Select a character item to drop."
		return
	var location_id := _get_current_location_id()
	if location_id.is_empty():
		_inventory_status_label.text = "Current location is missing an id."
		return
	var quantity := int(_quantity_spin_box.value)
	_transfer_item_async(_selected_character_item_id, "character", _character_id, "location", location_id, null, quantity, "Dropped item.")


func _on_inventory_pickup_pressed() -> void:
	if _selected_ground_item_id.is_empty():
		_inventory_status_label.text = "Select a ground item to pick up."
		return
	var location_id := _get_current_location_id()
	if location_id.is_empty():
		_inventory_status_label.text = "Current location is missing an id."
		return
	var quantity := int(_quantity_spin_box.value)
	_transfer_item_async(_selected_ground_item_id, "location", location_id, "character", _character_id, null, quantity, "Picked up item.")


func _on_inventory_refresh_pressed() -> void:
	_inventory_status_label.text = "Refreshing..."
	_refresh_inventory_menu_data()


func _on_inventory_close_pressed() -> void:
	_close_inventory_menu()


func _move_character_item_async(item_id: String, to_slot: int, quantity: int) -> void:
	if _inventory_request_in_flight:
		return
	_inventory_request_in_flight = true
	_inventory_status_label.text = "Moving item..."

	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)
	headers.append("Content-Type: application/json")

	var body := JSON.stringify({
		"itemId": item_id,
		"toSlot": to_slot,
		"quantity": quantity
	})
	var err := request.request("%s/by-owner/character/%s/move" % [INVENTORY_API_URL, _character_id], headers, HTTPClient.METHOD_POST, body)
	if err != OK:
		request.queue_free()
		_inventory_status_label.text = "Move request failed."
		_inventory_request_in_flight = false
		return

	var result: Array = await request.request_completed
	request.queue_free()
	_inventory_request_in_flight = false
	_handle_inventory_mutation_response(result, "Item moved.")


func _transfer_item_async(item_id: String, from_owner_type: String, from_owner_id: String, to_owner_type: String, to_owner_id: String, to_slot: Variant, quantity: int, success_message: String) -> void:
	if _inventory_request_in_flight:
		return
	_inventory_request_in_flight = true
	_inventory_status_label.text = "Transferring item..."

	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)
	headers.append("Content-Type: application/json")

	var payload := {
		"itemId": item_id,
		"fromOwnerType": from_owner_type,
		"fromOwnerId": from_owner_id,
		"toOwnerType": to_owner_type,
		"toOwnerId": to_owner_id,
		"quantity": quantity
	}
	if typeof(to_slot) != TYPE_NIL:
		payload["toSlot"] = int(to_slot)

	var err := request.request("%s/transfer" % INVENTORY_API_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
	if err != OK:
		request.queue_free()
		_inventory_status_label.text = "Transfer request failed."
		_inventory_request_in_flight = false
		return

	var result: Array = await request.request_completed
	request.queue_free()
	_inventory_request_in_flight = false
	_handle_inventory_mutation_response(result, success_message)


func _handle_inventory_mutation_response(result: Array, success_message: String) -> void:
	var result_code: int = result[0]
	var response_code: int = result[1]
	var response_body: String = result[3].get_string_from_utf8()
	if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
		_inventory_status_label.text = "Inventory action failed."
		push_warning("Inventory action failed (%s/%s): %s" % [result_code, response_code, response_body])
		return

	_inventory_status_label.text = success_message
	_refresh_inventory_menu_data()


func _fetch_character_inventory() -> Array:
	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)

	var err := request.request("%s/by-owner/character/%s" % [INVENTORY_API_URL, _character_id], headers, HTTPClient.METHOD_GET)
	if err != OK:
		request.queue_free()
		push_warning("Failed to request character inventory: %s" % err)
		return []

	var result: Array = await request.request_completed
	request.queue_free()

	var result_code: int = result[0]
	var response_code: int = result[1]
	var response_body: String = result[3].get_string_from_utf8()
	if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
		push_warning("Failed to load character inventory (%s/%s): %s" % [result_code, response_code, response_body])
		return []

	var parsed: Variant = JSON.parse_string(response_body)
	if typeof(parsed) != TYPE_DICTIONARY:
		return []

	var payload := parsed as Dictionary
	return _parse_floor_inventory_items(payload.get("items", []))


func _refresh_mailbox() -> void:
	if _mail_request_in_flight or _character_id.is_empty():
		return
	_refresh_mailbox_async()


func _refresh_mailbox_async() -> void:
	_mail_request_in_flight = true
	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)

	var err := request.request("%s/characters/%s" % [MAIL_API_URL, _character_id], headers, HTTPClient.METHOD_GET)
	if err != OK:
		request.queue_free()
		_mail_status_label.text = "Failed to load mailbox."
		_mail_request_in_flight = false
		return

	var result: Array = await request.request_completed
	request.queue_free()
	_mail_request_in_flight = false

	var result_code: int = result[0]
	var response_code: int = result[1]
	var response_body: String = result[3].get_string_from_utf8()
	if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
		_mail_status_label.text = "Failed to load mailbox."
		push_warning("Failed to load mailbox (%s/%s): %s" % [result_code, response_code, response_body])
		return

	var parsed: Variant = JSON.parse_string(response_body)
	if typeof(parsed) != TYPE_DICTIONARY:
		_mail_status_label.text = "Mailbox response was invalid."
		return

	var mailbox := parsed as Dictionary
	_mail_inbox = _parse_mail_messages(mailbox.get("inbox", []))
	_mail_sent = _parse_mail_messages(mailbox.get("sent", []))
	_render_mailbox()
	_mail_status_label.text = ""


func _render_mailbox() -> void:
	var current_inbox_id := _selected_inbox_mail_id
	var current_sent_id := _selected_sent_mail_id
	_mail_inbox_items.clear()
	for index in range(_mail_inbox.size()):
		var message := _mail_inbox[index] as Dictionary
		var prefix := "[NEW] " if String(message.get("readUtc", "")).is_empty() else ""
		var text := "%s%s | %s" % [
			prefix,
			String(message.get("senderCharacterName", "")).strip_edges(),
			String(message.get("subject", "")).strip_edges()
		]
		_mail_inbox_items.add_item(text)
		_mail_inbox_items.set_item_metadata(index, message)
		if String(message.get("id", "")).strip_edges() == current_inbox_id:
			_mail_inbox_items.select(index)

	_mail_sent_items.clear()
	for index in range(_mail_sent.size()):
		var message := _mail_sent[index] as Dictionary
		var text := "%s | %s" % [
			String(message.get("recipientCharacterName", "")).strip_edges(),
			String(message.get("subject", "")).strip_edges()
		]
		_mail_sent_items.add_item(text)
		_mail_sent_items.set_item_metadata(index, message)
		if String(message.get("id", "")).strip_edges() == current_sent_id:
			_mail_sent_items.select(index)

	_update_mail_selection_display()


func _parse_mail_messages(value: Variant) -> Array:
	var messages: Array = []
	if typeof(value) != TYPE_ARRAY:
		return messages

	for entry in value:
		if typeof(entry) != TYPE_DICTIONARY:
			continue
		var message := entry as Dictionary
		messages.append({
			"id": str(message.get("id", "")).strip_edges(),
			"senderCharacterId": str(message.get("senderCharacterId", "")).strip_edges(),
			"senderCharacterName": str(message.get("senderCharacterName", "")).strip_edges(),
			"recipientCharacterId": str(message.get("recipientCharacterId", "")).strip_edges(),
			"recipientCharacterName": str(message.get("recipientCharacterName", "")).strip_edges(),
			"subject": str(message.get("subject", "")).strip_edges(),
			"body": str(message.get("body", "")).strip_edges(),
			"createdUtc": str(message.get("createdUtc", "")).strip_edges(),
			"readUtc": str(message.get("readUtc", "")).strip_edges()
		})

	return messages


func _update_mail_selection_display() -> void:
	var selected_message := {}
	if not _selected_inbox_mail_id.is_empty():
		selected_message = _find_mail_message_by_id(_mail_inbox, _selected_inbox_mail_id)
	elif not _selected_sent_mail_id.is_empty():
		selected_message = _find_mail_message_by_id(_mail_sent, _selected_sent_mail_id)

	if selected_message.is_empty():
		_mail_selected_label.text = "Selected Mail"
		_mail_selected_body.text = ""
		return

	var subject := String(selected_message.get("subject", "")).strip_edges()
	var sender_name := String(selected_message.get("senderCharacterName", "")).strip_edges()
	var recipient_name := String(selected_message.get("recipientCharacterName", "")).strip_edges()
	_mail_selected_label.text = "%s -> %s | %s" % [sender_name, recipient_name, subject]
	_mail_selected_body.text = String(selected_message.get("body", "")).strip_edges()


func _find_mail_message_by_id(messages: Array, message_id: String) -> Dictionary:
	for message_variant in messages:
		if typeof(message_variant) != TYPE_DICTIONARY:
			continue
		var message := message_variant as Dictionary
		if String(message.get("id", "")).strip_edges() == message_id:
			return message
	return {}


func _on_mail_inbox_selected(index: int) -> void:
	var metadata: Variant = _mail_inbox_items.get_item_metadata(index)
	if typeof(metadata) != TYPE_DICTIONARY:
		return
	var message := metadata as Dictionary
	_selected_sent_mail_id = ""
	_mail_sent_items.deselect_all()
	_selected_inbox_mail_id = String(message.get("id", "")).strip_edges()
	_update_mail_selection_display()
	_mark_mail_read(_selected_inbox_mail_id)


func _on_mail_sent_selected(index: int) -> void:
	var metadata: Variant = _mail_sent_items.get_item_metadata(index)
	if typeof(metadata) != TYPE_DICTIONARY:
		return
	var message := metadata as Dictionary
	_selected_inbox_mail_id = ""
	_mail_inbox_items.deselect_all()
	_selected_sent_mail_id = String(message.get("id", "")).strip_edges()
	_update_mail_selection_display()


func _mark_mail_read(message_id: String) -> void:
	if message_id.is_empty():
		return
	_mark_mail_read_async(message_id)


func _mark_mail_read_async(message_id: String) -> void:
	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)

	var err := request.request("%s/characters/%s/messages/%s/read" % [MAIL_API_URL, _character_id, message_id], headers, HTTPClient.METHOD_POST)
	if err != OK:
		request.queue_free()
		return

	var result: Array = await request.request_completed
	request.queue_free()
	var result_code: int = result[0]
	var response_code: int = result[1]
	if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
		return

	for index in range(_mail_inbox.size()):
		var message := _mail_inbox[index] as Dictionary
		if String(message.get("id", "")).strip_edges() != message_id:
			continue
		message["readUtc"] = "read"
		_mail_inbox[index] = message
		break
	_render_mailbox()


func _on_mail_refresh_pressed() -> void:
	_mail_status_label.text = "Refreshing mailbox..."
	_refresh_mailbox()


func _on_mail_send_pressed() -> void:
	var recipient_name := _mail_recipient_line_edit.text.strip_edges()
	var subject := _mail_subject_line_edit.text.strip_edges()
	var body := _mail_compose_body.text.strip_edges()
	if recipient_name.is_empty():
		_mail_status_label.text = "Recipient character name is required."
		return
	if subject.is_empty():
		_mail_status_label.text = "Subject is required."
		return
	if body.is_empty():
		_mail_status_label.text = "Body is required."
		return
	_send_mail_async(recipient_name, subject, body)


func _send_mail_async(recipient_name: String, subject: String, body: String) -> void:
	if _mail_request_in_flight:
		return
	_mail_request_in_flight = true
	_mail_status_label.text = "Sending mail..."

	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)
	headers.append("Content-Type: application/json")

	var payload := JSON.stringify({
		"recipientCharacterName": recipient_name,
		"subject": subject,
		"body": body
	})
	var err := request.request("%s/characters/%s/send" % [MAIL_API_URL, _character_id], headers, HTTPClient.METHOD_POST, payload)
	if err != OK:
		request.queue_free()
		_mail_request_in_flight = false
		_mail_status_label.text = "Failed to send mail."
		return

	var result: Array = await request.request_completed
	request.queue_free()
	_mail_request_in_flight = false

	var result_code: int = result[0]
	var response_code: int = result[1]
	var response_body: String = result[3].get_string_from_utf8()
	if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
		_mail_status_label.text = "Failed to send mail."
		push_warning("Failed to send mail (%s/%s): %s" % [result_code, response_code, response_body])
		return

	_mail_recipient_line_edit.text = ""
	_mail_subject_line_edit.text = ""
	_mail_compose_body.text = ""
	_mail_status_label.text = "Mail sent."
	_refresh_mailbox()


func _on_mail_close_pressed() -> void:
	_close_mail_menu()


func _ensure_selected_location_exists(coord: Vector2i) -> void:
	if _known_locations.has(coord):
		return
	_known_locations[coord] = {
		"id": "",
		"name": _selected_location_name(coord),
		"biomeKey": "plains",
		"elevation": 0,
		"locationObject": {},
		"floorItems": []
	}


func _selected_location_name(coord: Vector2i) -> String:
	var selected_name := String(SelectedCharacter.character.get("locationName", "")).strip_edges()
	if not selected_name.is_empty():
		return selected_name
	var character_name := String(SelectedCharacter.character.get("name", "")).strip_edges()
	if not character_name.is_empty():
		return "%s's Location" % character_name
	return "Location %d,%d" % [coord.x, coord.y]


func _load_existing_locations() -> void:
	_locations_refresh_in_flight = true
	_locations_loaded = false
	_known_locations.clear()

	if _character_id.is_empty():
		push_warning("Selected character is missing an id; cannot load visible locations.")
		_locations_loaded = true
		_locations_refresh_in_flight = false
		return

	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)

	var err := request.request("%s/%s/visible-locations" % [CHARACTER_API_URL, _character_id], headers, HTTPClient.METHOD_GET)
	if err != OK:
		push_warning("Failed to request visible locations: %s" % err)
		request.queue_free()
		_locations_loaded = true
		_locations_refresh_in_flight = false
		return

	var result: Array = await request.request_completed
	request.queue_free()

	var result_code: int = result[0]
	var response_code: int = result[1]
	var response_body: String = result[3].get_string_from_utf8()
	if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
		push_warning("Failed to load visible locations (%s/%s): %s" % [result_code, response_code, response_body])
		_locations_loaded = true
		_locations_refresh_in_flight = false
		return

	var parsed: Variant = JSON.parse_string(response_body)
	if typeof(parsed) != TYPE_ARRAY:
		push_warning("Visible locations response was not an array.")
		_locations_loaded = true
		_locations_refresh_in_flight = false
		return

	var loaded_count := 0
	for item in parsed:
		if typeof(item) != TYPE_DICTIONARY:
			continue
		var location := item as Dictionary
		var coord_variant: Variant = location.get("coord", {})
		if typeof(coord_variant) != TYPE_DICTIONARY:
			continue
		var coord_dict := coord_variant as Dictionary
		var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0)))
		var location_name := String(location.get("name", "")).strip_edges()
		if location_name.is_empty():
			location_name = "Location %d,%d" % [coord.x, coord.y]
		_known_locations[coord] = {
			"id": String(location.get("id", "")).strip_edges(),
			"name": location_name,
			"biomeKey": String(location.get("biomeKey", "plains")).strip_edges(),
			"elevation": int(location.get("elevation", 0)),
			"locationObject": _parse_location_object(location.get("locationObject", {})),
			"floorItems": _parse_floor_inventory_items(location.get("floorItems", []))
		}
		loaded_count += 1

	print("LocationLevel loaded %d visible locations for character %s." % [loaded_count, _character_id])
	if loaded_count == 0:
		push_warning("Visible locations request succeeded but returned 0 locations for character %s." % _character_id)

	_locations_loaded = true
	_locations_refresh_in_flight = false
	_rebuild_tiles(_center_coord)
	_refresh_visible_characters()
	if _queued_locations_refresh:
		_queued_locations_refresh = false
		_queue_locations_refresh()


func _queue_locations_refresh() -> void:
	if _locations_refresh_in_flight:
		_queued_locations_refresh = true
		return
	_refresh_visible_locations()


func _refresh_visible_locations() -> void:
	if _character_id.is_empty():
		return
	_refresh_visible_locations_async()


func _refresh_visible_locations_async() -> void:
	await _load_existing_locations()


func _try_interact_current_tile() -> void:
	if _interact_in_flight:
		return
	if _character_id.is_empty():
		return

	var location_data: Dictionary = _known_locations.get(_center_coord, {})
	if location_data.is_empty():
		push_warning("No known location data for %s." % _center_coord)
		return

	var location_id := String(location_data.get("id", "")).strip_edges()
	if location_id.is_empty():
		push_warning("Current location is missing an id.")
		return

	var object_data: Dictionary = location_data.get("locationObject", {})
	if object_data.is_empty():
		push_warning("Current location has no interactable object.")
		return

	var object_id := String(object_data.get("id", "")).strip_edges()
	if object_id.is_empty():
		push_warning("Current location object is missing an id.")
		return

	_interact_in_flight = true
	_interact_with_location_async(location_id, object_id)


func _interact_with_location_async(location_id: String, object_id: String) -> void:
	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)
	headers.append("Content-Type: application/json")

	var body := JSON.stringify({
		"characterId": _character_id,
		"objectId": object_id
	})
	var err := request.request("%s/%s/interact" % [LOCATION_API_URL, location_id], headers, HTTPClient.METHOD_POST, body)
	if err != OK:
		push_warning("Failed to send location interaction request: %s" % err)
		request.queue_free()
		_interact_in_flight = false
		return

	var result: Array = await request.request_completed
	request.queue_free()

	var result_code: int = result[0]
	var response_code: int = result[1]
	var response_body: String = result[3].get_string_from_utf8()
	if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
		push_warning("Location interaction failed (%s/%s): %s" % [result_code, response_code, response_body])
		_interact_in_flight = false
		return

	var parsed: Variant = JSON.parse_string(response_body)
	if typeof(parsed) != TYPE_DICTIONARY:
		push_warning("Location interaction response was not an object.")
		_interact_in_flight = false
		return

	var interaction := parsed as Dictionary
	if String(interaction.get("objectType", "")).strip_edges() == "mailbox":
		_open_mail_menu()
		_interact_in_flight = false
		return
	_apply_interaction_result(location_id, interaction)
	await _refresh_location_inventory(location_id)
	_interact_in_flight = false


func _apply_interaction_result(location_id: String, interaction: Dictionary) -> void:
	var consumed := bool(interaction.get("consumed", false))
	var remaining_quantity := int(interaction.get("remainingQuantity", 0))

	for coord_variant in _known_locations.keys():
		var coord: Vector2i = coord_variant
		var location_data: Dictionary = _known_locations[coord]
		if String(location_data.get("id", "")) != location_id:
			continue

		var updated_location := location_data.duplicate(true)
		if consumed:
			updated_location["locationObject"] = {}
		else:
			var object_data: Dictionary = updated_location.get("locationObject", {})
			var state: Dictionary = object_data.get("state", {})
			state["remainingQuantity"] = remaining_quantity
			object_data["state"] = state
			updated_location["locationObject"] = object_data
		_known_locations[coord] = updated_location
		_update_tile(coord, updated_location)
		return


func _queue_coord_sync(coord: Vector2i) -> void:
	if coord == _persisted_coord:
		return
	if _coord_sync_in_flight:
		_queued_coord_sync = coord
		return
	_sync_character_coord(coord)


func _sync_character_coord(coord: Vector2i) -> void:
	if _character_id.is_empty():
		return
	_coord_sync_in_flight = true
	_queued_coord_sync = null
	_sync_character_coord_async(coord)


func _sync_character_coord_async(coord: Vector2i) -> void:
	var response := await CharacterService.update_character_coord(_character_id, coord)
	if response.get("ok", false):
		_persisted_coord = coord
		SelectedCharacter.set_coord(coord)
		_refresh_visible_characters()
	else:
		push_warning("Failed to persist character coord to %s,%s: status=%s error=%s body=%s" % [
			coord.x,
			coord.y,
			response.get("status", "n/a"),
			response.get("error", ""),
			response.get("body", "")
		])

	_coord_sync_in_flight = false
	if _queued_coord_sync != null and _queued_coord_sync is Vector2i and _queued_coord_sync != _persisted_coord:
		var queued_coord: Vector2i = _queued_coord_sync
		_sync_character_coord(queued_coord)


func _get_location_data(coord: Vector2i) -> Dictionary:
	var value: Variant = _known_locations.get(coord, {})
	if typeof(value) == TYPE_DICTIONARY:
		return value as Dictionary
	return {}


func _parse_location_object(value: Variant) -> Dictionary:
	if typeof(value) != TYPE_DICTIONARY:
		return {}

	var object_data := value as Dictionary
	var state_value: Variant = object_data.get("state", {})
	var state: Dictionary = {}
	if typeof(state_value) == TYPE_DICTIONARY:
		var raw_state := state_value as Dictionary
		state = {
			"itemKey": String(raw_state.get("itemKey", "")).strip_edges(),
			"remainingQuantity": int(raw_state.get("remainingQuantity", 0)),
			"gatherQuantity": int(raw_state.get("gatherQuantity", 1))
		}

	return {
		"id": String(object_data.get("id", "")).strip_edges(),
		"objectType": String(object_data.get("objectType", "")).strip_edges(),
		"objectKey": String(object_data.get("objectKey", "")).strip_edges(),
		"name": String(object_data.get("name", "")).strip_edges(),
		"state": state
	}


func _parse_floor_inventory_items(value: Variant) -> Array:
	var items: Array = []
	if typeof(value) != TYPE_ARRAY:
		return items

	for entry in value:
		if typeof(entry) != TYPE_DICTIONARY:
			continue
		var item := entry as Dictionary
		items.append({
			"itemId": String(item.get("itemId", item.get("id", ""))).strip_edges(),
			"itemKey": String(item.get("itemKey", "")).strip_edges(),
			"quantity": int(item.get("quantity", 0)),
			"slot": item.get("slot", null)
		})

	return items

func _refresh_location_inventory(location_id: String) -> void:
	if location_id.is_empty():
		return
	var floor_items := await _fetch_location_inventory(location_id)
	for coord_variant in _known_locations.keys():
		var coord: Vector2i = coord_variant
		var location_data: Dictionary = _known_locations[coord]
		if String(location_data.get("id", "")).strip_edges() != location_id:
			continue
		location_data["floorItems"] = floor_items
		_known_locations[coord] = location_data
		_update_tile(coord, location_data)
		return


func _fetch_location_inventory(location_id: String) -> Array:
	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)

	var err := request.request("%s/by-owner/location/%s" % [INVENTORY_API_URL, location_id], headers, HTTPClient.METHOD_GET)
	if err != OK:
		request.queue_free()
		push_warning("Failed to request floor inventory for location %s: %s" % [location_id, err])
		return []

	var result: Array = await request.request_completed
	request.queue_free()

	var result_code: int = result[0]
	var response_code: int = result[1]
	var response_body: String = result[3].get_string_from_utf8()
	if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
		push_warning("Failed to load floor inventory for location %s (%s/%s): %s" % [location_id, result_code, response_code, response_body])
		return []

	var parsed: Variant = JSON.parse_string(response_body)
	if typeof(parsed) != TYPE_DICTIONARY:
		return []

	var payload := parsed as Dictionary
	return _parse_floor_inventory_items(payload.get("items", []))


func _refresh_visible_characters() -> void:
	if _visible_characters_in_flight or _character_id.is_empty():
		return
	_refresh_visible_characters_async()


func _refresh_visible_characters_async() -> void:
	_visible_characters_in_flight = true
	var request := HTTPRequest.new()
	add_child(request)

	var headers := PackedStringArray()
	if not AuthState.access_token.is_empty():
		headers.append("Authorization: Bearer %s" % AuthState.access_token)

	var err := request.request("%s/%s/visible-characters" % [CHARACTER_API_URL, _character_id], headers, HTTPClient.METHOD_GET)
	if err != OK:
		request.queue_free()
		push_warning("Failed to request visible characters: %s" % err)
		_visible_characters_in_flight = false
		return

	var result: Array = await request.request_completed
	request.queue_free()
	_visible_characters_in_flight = false

	var result_code: int = result[0]
	var response_code: int = result[1]
	var response_body: String = result[3].get_string_from_utf8()
	if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
		push_warning("Failed to load visible characters (%s/%s): %s" % [result_code, response_code, response_body])
		return

	var parsed: Variant = JSON.parse_string(response_body)
	if typeof(parsed) != TYPE_ARRAY:
		return

	_apply_visible_characters(parsed as Array)


func _apply_visible_characters(characters: Array) -> void:
	var wanted_ids := {}
	for character_variant in characters:
		if typeof(character_variant) != TYPE_DICTIONARY:
			continue
		var character := character_variant as Dictionary
		var character_id := String(character.get("id", "")).strip_edges()
		if character_id.is_empty() or character_id == _character_id:
			continue
		wanted_ids[character_id] = true
		_upsert_remote_character(character)

	for character_id in _remote_character_nodes.keys():
		if wanted_ids.has(character_id):
			continue
		var remote_node := _remote_character_nodes[character_id] as Node3D
		if remote_node:
			remote_node.queue_free()
		_remote_character_nodes.erase(character_id)


func _upsert_remote_character(character: Dictionary) -> void:
	var character_id := String(character.get("id", "")).strip_edges()
	var coord_value: Variant = character.get("coord", {})
	if typeof(coord_value) != TYPE_DICTIONARY:
		return
	var coord_dict := coord_value as Dictionary
	var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0)))

	var remote_root := _remote_character_nodes.get(character_id) as Node3D
	if remote_root == null:
		remote_root = Node3D.new()
		remote_root.name = "RemoteCharacter_%s" % character_id
		_remote_players_root.add_child(remote_root)

		var visual := _player_visual.duplicate() as Node3D
		visual.name = "Visual"
		visual.visible = true
		remote_root.add_child(visual)

		var label := Label3D.new()
		label.name = "NameLabel"
		label.position = Vector3(0.0, 1.8, 0.0)
		label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
		label.pixel_size = 0.01
		label.outline_size = 10
		remote_root.add_child(label)

		_remote_character_nodes[character_id] = remote_root

	remote_root.position = Vector3(coord.x * tile_size, _get_tile_surface_y(coord), coord.y * tile_size)
	var name_label := remote_root.get_node_or_null("NameLabel") as Label3D
	if name_label:
		name_label.text = String(character.get("name", "Player")).strip_edges()


func _send_presence_heartbeat() -> void:
	if _heartbeat_in_flight or _character_id.is_empty():
		return
	_send_presence_heartbeat_async()


func _send_presence_heartbeat_async() -> void:
	_heartbeat_in_flight = true
	var response := await CharacterService.heartbeat_character(_character_id)
	if not response.get("ok", false):
		push_warning("Failed to send presence heartbeat: status=%s error=%s body=%s" % [
			response.get("status", "n/a"),
			response.get("error", ""),
			response.get("body", "")
		])
	_heartbeat_in_flight = false


func _get_biome_material(tile: MeshInstance3D, biome_key: String) -> Material:
	var normalized_biome := biome_key if not biome_key.is_empty() else "plains"
	if _biome_materials.has(normalized_biome):
		return _biome_materials[normalized_biome]

	var source_material := tile.get_active_material(0)
	if source_material is StandardMaterial3D:
		var material := (source_material as StandardMaterial3D).duplicate() as StandardMaterial3D
		material.albedo_color = _get_biome_color(normalized_biome)
		_biome_materials[normalized_biome] = material
		return material

	return source_material


func _get_biome_color(biome_key: String) -> Color:
	match biome_key:
		"forest":
			return Color(0.36, 0.62, 0.34, 1.0)
		"wetlands":
			return Color(0.28, 0.52, 0.44, 1.0)
		"rocky":
			return Color(0.52, 0.50, 0.44, 1.0)
		"desert":
			return Color(0.76, 0.67, 0.38, 1.0)
		_:
			return Color(0.56, 0.72, 0.38, 1.0)
