class_name PlayerMenu
extends Control

const INPUT_OWNER: StringName = &"player_menu"
const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.gd")
const FishDataType = preload("res://fish/fish_data.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
const FishSaleResultType = preload("res://economy/fish_sale_result.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
const NetworkSessionType = preload("res://network/network_session.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const InventoryNotepadType = preload(
	"res://ui/components/inventory_notepad.gd"
)
const OrganizerTabType = preload("res://ui/components/organizer_tab.gd")
const FishPoolType = preload("res://fish/fish_pool.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const PlayerType = preload("res://player/player.gd")
const PlayerWalletType = preload("res://economy/player_wallet.gd")
const ItemCatalogType = preload("res://items/item_catalog.gd")
const ItemDataType = preload("res://items/item_data.gd")
const OwnedItemType = preload("res://items/owned_item.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const PlayerCoolerCapacityType = preload(
	"res://progression/player_cooler_capacity.gd"
)
const PlayerExperienceType = preload(
	"res://progression/player_experience.gd"
)
const ControllerMappingManagerType = preload(
	"res://settings/controller_mapping_manager.gd"
)
const FishBatchSelectionType = preload(
	"res://ui/fish_batch_selection.gd"
)
const BubbleButtonType = preload(
	"res://ui/components/bubble_menu/bubble_button.gd"
)
const BubbleClusterType = preload(
	"res://ui/components/bubble_menu/bubble_cluster.gd"
)
const BubbleContentShellType = preload(
	"res://ui/components/bubble_menu/bubble_content_shell.gd"
)
const BubbleStatusBubbleType = preload(
	"res://ui/components/bubble_menu/bubble_status_bubble.gd"
)
const NotepadInkActionType = preload(
	"res://ui/components/bubble_menu/notepad_ink_action.gd"
)
const NotepadInkChoiceType = preload(
	"res://ui/components/bubble_menu/notepad_ink_choice.gd"
)
const CoolerFishSpriteType = preload(
	"res://ui/components/bubble_menu/cooler_fish_sprite.gd"
)
const CoolerFishSpriteScene = preload(
	"res://ui/components/bubble_menu/cooler_fish_sprite.tscn"
)
const BagItemSpriteType = preload(
	"res://ui/components/bubble_menu/bag_item_sprite.gd"
)
const BagItemSpriteScene = preload(
	"res://ui/components/bubble_menu/bag_item_sprite.tscn"
)
const LogbookEntryType = preload(
	"res://ui/components/bubble_menu/logbook_entry.gd"
)
const LogbookEntryScene = preload(
	"res://ui/components/bubble_menu/logbook_entry.tscn"
)

const LOGBOOK_PAGE_DURATION: float = 0.18
const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0)
const COMPACT_REFERENCE_SIZE := Vector2(640.0, 480.0)
const COMPACT_HEIGHT_THRESHOLD: float = 560.0
const NAVIGATION_PRESENTATION_SCALE: float = 0.60
const NAVIGATION_CANONICAL_POSITION := Vector2(424.0, 44.0)
const NAVIGATION_SELECTED_SCALE: float = 1.02
const INVENTORY_TAB_LEFT_INSET: float = 96.0
const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"

signal menu_visibility_changed(is_open: bool)
signal inventory_hotbar_context_changed(show_hotbar: bool)
signal controller_hotbar_placement_requested(
	assignment_kind: PlayerHotbarType.AssignmentKind,
	identity: StringName,
	initial_slot: int,
)
signal controller_hotbar_placement_ended
signal controller_hotbar_management_requested(initial_slot: int)
signal controller_hotbar_management_ended
signal menu_exit_started
signal shop_cooler_modal_changed(is_open: bool)

enum Section {
	COOLER,
	BAG,
	TACKLE_BOX,
	LOGBOOK,
	NET,
	MAIL,
	PROFILE,
	PLAYERS,
}

enum BagView {
	EQUIPMENT,
	CONSUMABLES,
}

enum TackleView {
	BAIT,
	LURES,
}

enum SortMode {
	CATCH_ORDER,
	NAME,
	RARITY,
}

enum CloseReason {
	USER,
	BITE_STARTED,
	WATER_RECOVERY,
	GAME_MENU,
	SESSION_END,
	TEARDOWN,
}

enum ControllerOwnership {
	ITEM_LIST,
	NOTEPAD_ACTIONS,
	HOTBAR_MANAGEMENT,
	HOTBAR_PLACEMENT,
}

const INVENTORY_MAIN_POSITION := Vector2(54.0, 166.0)
const INVENTORY_MAIN_SIZE := Vector2(882.0, 484.0)
const INVENTORY_PANEL_GAP := 16.0
const INVENTORY_NOTEPAD_POSITION := Vector2(
	INVENTORY_MAIN_POSITION.x + INVENTORY_MAIN_SIZE.x + INVENTORY_PANEL_GAP,
	INVENTORY_MAIN_POSITION.y,
)
const INVENTORY_NOTEPAD_SIZE := Vector2(278.0, 484.0)
const INVENTORY_HEADER_INSET := Vector2(24.0, 20.0)
const INVENTORY_HEADER_HEIGHT := 40.0
const INVENTORY_MAIN_CORNER_RADIUS: int = 58
const INVENTORY_INNER_CORNER_RADIUS: int = 45
const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0)

@onready var _navigation_cluster: BubbleClusterType = %NavigationCluster
@onready var _presentation_scale_root: Control = %PlayerMenuPresentationScaleRoot
@onready var _cooler_page: Control = %CoolerPage
@onready var _cooler_outer_wall: PanelContainer = %CoolerOuterWall
@onready var _cooler_inner_liner: PanelContainer = %CoolerInnerLiner
@onready var _cooler_water_surface: ColorRect = %WaterSurface
@onready var _fish_field: Control = %FishField
@onready var _cooler_sort_controls: HBoxContainer = %CoolerSortControls
@onready var _cooler_sort_option: NotepadInkChoiceType = %CoolerSortOption
@onready var _cooler_sort_direction: NotepadInkActionType = %CoolerSortDirection
@onready var _detail_constellation: Control = %DetailConstellation
@onready var _detail_bubble: Control = %DetailBubble
@onready var _notepad_binding: Label = %BindingDecoration
@onready var _notepad_title: Label = %NotepadTitle
@onready var _notepad_rule: ColorRect = %NotepadRule
@onready var _notepad_wallet_value: Label = %NotepadWalletValue
@onready var _notepad_capacity_value: Label = %NotepadCapacityValue
@onready var _cooler_detail_texture: TextureRect = %CoolerDetailTexture
@onready var _cooler_detail_name: Label = %CoolerDetailName
@onready var _cooler_detail_stats: Control = %CoolerDetailStats
@onready var _cooler_weight_row: HBoxContainer = %CoolerWeightRow
@onready var _cooler_weight_value: Label = %CoolerWeightValue
@onready var _cooler_weight_unit: Label = %CoolerWeightUnit
@onready var _cooler_offer_row: HBoxContainer = %CoolerOfferRow
@onready var _cooler_offer_label: Label = %CoolerOfferLabel
@onready var _cooler_offer_value: Label = %CoolerOfferValue
@onready var _cooler_selection_summary: Control = %CoolerSelectionSummary
@onready var _cooler_selection_empty: Label = %CoolerSelectionEmpty
@onready var _cooler_selected_count_row: HBoxContainer = %CoolerSelectedCountRow
@onready var _cooler_selected_count_value: Label = %CoolerSelectedCountValue
@onready var _cooler_selected_count_label: Label = %CoolerSelectedCountLabel
@onready var _cooler_combined_offer_row: HBoxContainer = %CoolerCombinedOfferRow
@onready var _cooler_combined_offer_label: Label = %CoolerCombinedOfferLabel
@onready var _cooler_combined_offer_value: Label = %CoolerCombinedOfferValue
@onready var _favorite_bubble: NotepadInkActionType = %FavoriteBubble
@onready var _sell_bubble: NotepadInkActionType = %SellBubble
@onready var _sell_all_bubble: NotepadInkActionType = %SellAllBubble
@onready var _bag_page: Control = %BagPage
@onready var _tackle_box_page: Control = %TackleBoxPage
@onready var _inventory_sub_tabs: HBoxContainer = %InventorySubTabs
@onready var _cooler_sub_tab: Button = %CoolerSubTab
@onready var _bag_sub_tab: Button = %BagSubTab
@onready var _tackle_sub_tab: Button = %TackleSubTab
@onready var _bag_filter_tabs: HBoxContainer = %BagFilterTabs
@onready var _equipment_filter: Button = %EquipmentFilter
@onready var _consumables_filter: Button = %ConsumablesFilter
@onready var _tackle_main_panel: PanelContainer = %TackleMainPanel
@onready var _tackle_detail_panel: PanelContainer = %TackleDetailPanel
@onready var _bait_filter: Button = %BaitFilter
@onready var _lures_filter: Button = %LuresFilter
@onready var _tackle_empty: Label = %TackleEmpty
@onready var _tackle_detail_text: Label = %TackleDetailText
@onready var _tackle_equip_button: NotepadInkActionType = %TackleEquipButton
@onready var _tackle_item_list: VBoxContainer = %TackleItemList
@onready var _bag_outer_wall: PanelContainer = %BagOuterWall
@onready var _bag_inner_liner: PanelContainer = %BagInnerLiner
@onready var _bag_scroll: ScrollContainer = %BagScroll
@onready var _bag_host: Control = %BagHost
@onready var _bag_item_field: Control = %BagItemField
@onready var _bag_empty_state: Label = %BagEmptyState
@onready var _bag_detail_constellation: Control = %BagDetailConstellation
@onready var _bag_sprite_detail_texture: TextureRect = %BagSpriteDetailTexture
@onready var _bag_sprite_detail_name: Label = %BagSpriteDetailName
@onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData
@onready var _logbook_page: Control = %LogbookPage
@onready var _catalog_logbook: LogbookPage = %CatalogLogbook
@onready var _the_net_page: TheNetPage = %TheNetPage
@onready var _mail_page: MailPage = %MailPage
@onready var _profile_page: ProfilePage = %ProfilePage
@onready var _players_page: PlayersPage = %PlayersPage
@onready var _book_backing: PanelContainer = %BookBacking
@onready var _book_spread: BoxContainer = %BookSpread
@onready var _left_page: PanelContainer = %LeftPage
@onready var _right_page: PanelContainer = %RightPage
@onready var _left_heading: Label = %LeftHeading
@onready var _right_heading: Label = %RightHeading
@onready var _left_entry_field: BoxContainer = %LeftEntryField
@onready var _right_entry_field: BoxContainer = %RightEntryField
@onready var _logbook_empty_state: Label = %LogbookEmptyState
@onready var _logbook_previous: BubbleButtonType = %LogbookPrevious
@onready var _logbook_next: BubbleButtonType = %LogbookNext
@onready var _logbook_page_status: BubbleStatusBubbleType = %LogbookPageStatus
@onready var _inventory_tab: BubbleButtonType = %InventoryTab
@onready var _logbook_tab: BubbleButtonType = %LogbookTab
@onready var _the_net_tab: BubbleButtonType = %TheNetTab
@onready var _mail_tab: BubbleButtonType = %MailTab
@onready var _profile_tab: BubbleButtonType = %ProfileTab
@onready var _players_tab: BubbleButtonType = %PlayersTab
@onready var _mail_unread_badge: Label = %MailUnreadBadge
@onready var _close_button: BubbleButtonType = %CloseButton
@onready var _content_shell: BubbleContentShellType = %MenuPanel
@onready var _content_stage: Control = %Content
@onready var _cooler_scroll: ScrollContainer = %CoolerScroll
@onready var _cooler_host: Control = %CoolerHost
@onready var _wallet_balance: Label = %WalletBalance
@onready var _header: Control = %Header
@onready var _separator: Control = %Separator
@onready var _wallet_status: BubbleStatusBubbleType = %WalletStatus
@onready var _capacity_status: BubbleStatusBubbleType = %CapacityStatus
@onready var _held_value_status: BubbleStatusBubbleType = %HeldValueStatus
@onready var _selection_status: BubbleStatusBubbleType = %SelectionStatus
@onready var _offer_status: BubbleStatusBubbleType = %OfferStatus
@onready var _inventory_section: Control = %InventorySection
@onready var _bag_section: Control = %BagSection
@onready var _bag_empty: Label = %BagEmpty
@onready var _bag_grid: GridContainer = %BagGrid
@onready var _bag_list: Control = %BagList
@onready var _bag_detail: Control = %BagDetail
@onready var _bag_detail_texture: TextureRect = %BagDetailTexture
@onready var _bag_detail_name: Label = %BagDetailName
@onready var _bag_detail_data: Label = %BagDetailData
@onready var _sort_option: OptionButton = %SortOption
@onready var _sort_direction: Button = %SortDirection
@onready var _held_value: Label = %HeldValue
@onready var _cooler_count: Label = %CoolerCount
@onready var _inventory_empty: Label = %InventoryEmpty
@onready var _selection_summary: Label = %SelectionSummary
@onready var _favorite_button: Button = %FavoriteButton
@onready var _sell_button: Button = %SellButton
@onready var _sale_unavailable: Label = %SaleUnavailable
@onready var _transaction_feedback: Label = %TransactionFeedback
@onready var _sale_confirmation: PanelContainer = %SaleConfirmation
@onready var _confirmation_message: Label = %ConfirmationMessage
@onready var _confirm_sale_button: Button = %ConfirmSaleButton
@onready var _cancel_sale_button: Button = %CancelSaleButton
@onready var _logbook_empty: Label = %LogbookEmpty

var _compact_layout: bool = false
var _player: PlayerType
var _inventory: FishInventoryType
var _collection_log: CollectionLogType
var _wallet: PlayerWalletType
var _sale_service: FishSaleServiceType
var _network_session: NetworkSessionType
var _network_sale_service: NetworkSaleService
var _network_mail_service: NetworkMailService
var _reservations: PlayerAssetReservationService
var _network_profile_service: NetworkProfileService
var _network_player_list: NetworkPlayerListService
var _controller_mapping_manager: ControllerMappingManagerType
var _default_buyer: FishBuyerProfileType
var _sale_buyer_override: FishBuyerProfileType
var _shop_cooler_context_active: bool = false
var _cooler_original_parent: Node
var _cooler_original_index: int = -1
var _confirmation_original_parent: Node
var _confirmation_original_index: int = -1
var _catalog: FishPoolType
var _fishing_spot: FishingSpotType
var _bag: PlayerBagType
var _hotbar: PlayerHotbarType
var _item_catalog: ItemCatalogType
var _cooler_capacity: PlayerCoolerCapacityType
var _current_section: Section = Section.COOLER
var _last_inventory_section: Section = Section.COOLER
var _bag_view: BagView = BagView.EQUIPMENT
var _tackle_view: TackleView = TackleView.BAIT
var _selected_tackle_item_id: StringName
var _controller_ownership: ControllerOwnership = ControllerOwnership.ITEM_LIST
var _controller_source_section: Section = Section.COOLER
var _controller_source_identity: StringName
var _controller_notepad_actions: Array[BaseButton] = []
var _controller_hotbar_assignment_kind: PlayerHotbarType.AssignmentKind = (
	PlayerHotbarType.AssignmentKind.EMPTY
)
var _controller_hotbar_identity: StringName
var _controller_previous_hotbar_slot: int = 0
var _sort_mode: SortMode = SortMode.CATCH_ORDER
var _sort_descending: bool = true
var _fish_selection := FishBatchSelectionType.new()
var _selected_bag_item_id: StringName
var _prior_movement_enabled: bool = true
var _prior_camera_input_enabled: bool = true
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
var _control_snapshot_stored: bool = false
var _mouse_snapshot_stored: bool = false
var _menu_generation: int = 0
var _confirmation_catch_ids: Array[StringName] = []
var _confirmation_buyer: FishBuyerProfileType
var _confirmation_buyer_id: StringName
var _confirmation_generation: int = -1
var _sale_in_progress: bool = false
var _presentation_tween: Tween
var _page_tween: Tween
var _transition_generation: int = 0
var _page_transition_generation: int = 0
var _transitioning: bool = false
var _page_transitioning: bool = false
var _presentation_rest_position: Vector2 = Vector2.ZERO
var _content_rest_position: Vector2 = Vector2.ZERO
var _cooler_rest_position: Vector2 = Vector2.ZERO
var _bag_rest_position: Vector2 = Vector2.ZERO
var _tackle_rest_position: Vector2 = Vector2.ZERO
var _logbook_rest_position: Vector2 = Vector2.ZERO
var _the_net_rest_position: Vector2 = Vector2.ZERO
var _mail_rest_position: Vector2 = Vector2.ZERO
var _profile_rest_position: Vector2 = Vector2.ZERO
var _page_outgoing_root: Control
var _page_incoming_root: Control
var _page_outgoing_content_root: Control
var _inventory_transition_group: Control
var _fish_nodes: Dictionary[StringName, CoolerFishSpriteType] = {}
var _sorted_catches: Array[FishCatchType] = []
var _bag_item_nodes: Dictionary[StringName, BagItemSpriteType] = {}
var _sorted_bag_items: Array[OwnedItemType] = []
var _bag_drag_active: bool = false
var _motion_elapsed: float = 0.0
var _logbook_species: Array[FishDataType] = []
var _logbook_current_page: int = 0
var _logbook_page_count: int = 1
var _logbook_page_transitioning: bool = false
var _logbook_page_generation: int = 0
var _logbook_page_tween: Tween


func _ready() -> void:
	_configure_inventory_transition_group()
	_cooler_original_parent = _cooler_page.get_parent()
	_cooler_original_index = _cooler_page.get_index()
	_confirmation_original_parent = _sale_confirmation.get_parent()
	_confirmation_original_index = _sale_confirmation.get_index()
	_inventory_sub_tabs.move_child(_tackle_sub_tab, 1)
	_inventory_sub_tabs.move_child(_bag_sub_tab, 2)
	_inventory_tab.pressed.connect(_show_last_inventory_section)
	_cooler_sub_tab.pressed.connect(_show_section.bind(Section.COOLER))
	_bag_sub_tab.pressed.connect(_show_section.bind(Section.BAG))
	_tackle_sub_tab.pressed.connect(_show_section.bind(Section.TACKLE_BOX))
	_equipment_filter.pressed.connect(_set_bag_view.bind(BagView.EQUIPMENT))
	_consumables_filter.pressed.connect(
		_set_bag_view.bind(BagView.CONSUMABLES)
	)
	_bait_filter.pressed.connect(_set_tackle_view.bind(TackleView.BAIT))
	_lures_filter.pressed.connect(_set_tackle_view.bind(TackleView.LURES))
	_tackle_equip_button.pressed.connect(_toggle_active_tackle)
	_logbook_tab.pressed.connect(
		_show_section.bind(Section.LOGBOOK)
	)
	_the_net_tab.pressed.connect(_show_section.bind(Section.NET))
	_mail_tab.pressed.connect(_show_section.bind(Section.MAIL))
	_profile_tab.pressed.connect(_show_section.bind(Section.PROFILE))
	_players_tab.pressed.connect(_show_section.bind(Section.PLAYERS))
	_close_button.pressed.connect(close_menu)
	_sort_option.item_selected.connect(_on_sort_selected.bind(_sort_option))
	_sort_direction.pressed.connect(_on_sort_direction_pressed)
	_cooler_sort_option.item_selected.connect(_on_cooler_sort_selected)
	_cooler_sort_direction.pressed.connect(_on_sort_direction_pressed)
	_favorite_button.pressed.connect(_on_favorite_pressed)
	_sell_button.pressed.connect(_on_sell_pressed)
	_favorite_bubble.pressed.connect(_on_favorite_pressed)
	_sell_bubble.pressed.connect(_on_sell_pressed)
	_sell_all_bubble.pressed.connect(_on_sell_all_pressed)
	_confirm_sale_button.pressed.connect(_on_confirm_sale_pressed)
	_cancel_sale_button.pressed.connect(_close_sale_confirmation)
	_configure_sale_confirmation_focus()
	_sort_option.add_item("catch order", SortMode.CATCH_ORDER)
	_sort_option.add_item("name", SortMode.NAME)
	_sort_option.add_item("rarity", SortMode.RARITY)
	_sort_option.select(SortMode.CATCH_ORDER)
	_cooler_sort_option.add_item("catch order", SortMode.CATCH_ORDER)
	_cooler_sort_option.add_item("name", SortMode.NAME)
	_cooler_sort_option.add_item("rarity", SortMode.RARITY)
	_cooler_sort_option.select(SortMode.CATCH_ORDER)
	_update_sort_direction_text()
	_navigation_cluster.configure([
		_inventory_tab,
		_logbook_tab,
		_the_net_tab,
		_mail_tab,
		_profile_tab,
		_players_tab,
		_close_button,
	])
	_configure_navigation_focus()
	_apply_navigation_styles()
	_apply_inventory_styles()
	# Keep the exposed tab hitboxes above the full-page roots. The individual
	# main panels retain a higher z-index and cover the tab bodies below y=166.
	_inventory_sub_tabs.move_to_front()
	_bag_filter_tabs.move_to_front()
	_apply_cooler_control_styles()
	_apply_cooler_wall_styles()
	_apply_cooler_notepad_style()
	_cooler_water_surface.resized.connect(_update_cooler_water_mask)
	_fish_field.gui_input.connect(_on_fish_field_gui_input)
	_apply_bag_styles()
	_bag_item_field.gui_input.connect(_on_bag_field_gui_input)
	_apply_mail_notification_style()
	resized.connect(_update_shell_layout)
	_show_section_immediate(_current_section)
	call_deferred("_update_shell_layout")
	call_deferred("_update_cooler_water_mask")
	set_process(false)


func _configure_inventory_transition_group() -> void:
	# Keep the organizer row and the panel that masks its lower flange under one
	# alpha owner. Top-level page crossfades can then never expose a complete tab.
	var original_layer_index: int = mini(
		_inventory_sub_tabs.get_index(),
		mini(
			_cooler_page.get_index(),
			mini(_bag_page.get_index(), _tackle_box_page.get_index()),
		),
	)
	_inventory_transition_group = Control.new()
	_inventory_transition_group.name = "InventoryTransitionGroup"
	_inventory_transition_group.set_anchors_and_offsets_preset(
		Control.PRESET_FULL_RECT
	)
	_inventory_transition_group.mouse_filter = Control.MOUSE_FILTER_IGNORE
	_presentation_scale_root.add_child(_inventory_transition_group)
	# Adding a runtime container normally places it after NavigationCluster in
	# GUI picking order. Retain the pages' authored layer so their full-screen
	# PASS roots cannot cover the top-level navigation hitboxes.
	_presentation_scale_root.move_child(
		_inventory_transition_group,
		original_layer_index,
	)
	for control: Control in [
		_inventory_sub_tabs,
		_cooler_page,
		_bag_page,
		_tackle_box_page,
	]:
		control.reparent(_inventory_transition_group, true)


func _apply_mail_notification_style() -> void:
	var background := StyleBoxFlat.new()
	background.bg_color = Color(0.95, 0.88, 0.63, 0.98)
	background.border_color = Color(0.20, 0.14, 0.08, 0.92)
	background.set_border_width_all(2)
	background.set_corner_radius_all(12)
	background.content_margin_left = 5
	background.content_margin_right = 5
	background.content_margin_top = 2
	background.content_margin_bottom = 2
	_mail_unread_badge.add_theme_stylebox_override("normal", background)
	_mail_unread_badge.add_theme_font_override(
		"font", UtilityPageStyle.TuffyFont
	)


func _update_cooler_water_mask() -> void:
	var shader_material := _cooler_water_surface.material as ShaderMaterial
	if shader_material == null:
		return
	shader_material.set_shader_parameter(
		"rendered_size",
		_cooler_water_surface.size,
	)


func setup(
	player: PlayerType,
	inventory: FishInventoryType,
	collection_log: CollectionLogType,
	experience: PlayerExperienceType,
	wallet: PlayerWalletType,
	sale_service: FishSaleServiceType,
	default_buyer: FishBuyerProfileType,
	catalog: FishPoolType,
	fishing_spot: FishingSpotType,
	bag: PlayerBagType,
	hotbar: PlayerHotbarType,
	item_catalog: ItemCatalogType,
	cooler_capacity: PlayerCoolerCapacityType,
	network_session: NetworkSessionType,
	network_sale_service: NetworkSaleService,
	network_mail_service: NetworkMailService,
	reservations: PlayerAssetReservationService,
	network_profile_service: NetworkProfileService,
	network_player_list: NetworkPlayerListService,
	discovery: DiscoveryClient,
	player_jobs: PlayerJobService,
	world_time: WorldTimeService,
	world_environment: WorldEnvironment,
	world_sun: DirectionalLight3D,
) -> void:
	_player = player
	_inventory = inventory
	_collection_log = collection_log
	_wallet = wallet
	_sale_service = sale_service
	_default_buyer = default_buyer
	_catalog = catalog
	_fishing_spot = fishing_spot
	_bag = bag
	_hotbar = hotbar
	_item_catalog = item_catalog
	_cooler_capacity = cooler_capacity
	_network_session = network_session
	_network_sale_service = network_sale_service
	_network_mail_service = network_mail_service
	_reservations = reservations
	_network_profile_service = network_profile_service
	_network_player_list = network_player_list
	_mail_page.setup(
		network_mail_service, reservations, inventory, wallet, bag, item_catalog
	)
	_profile_page.setup(
		network_profile_service,
		experience,
		world_environment,
		world_sun,
	)
	_players_page.setup(network_player_list, discovery)
	_catalog_logbook.setup(collection_log, inventory, catalog)
	_the_net_page.setup(player_jobs, world_time)
	_network_mail_service.unread_count_changed.connect(
		_on_mail_unread_count_changed
	)
	_on_mail_unread_count_changed(_network_mail_service.get_unread_count())
	if (
		_network_sale_service != null
		and not _network_sale_service.local_sale_pending.is_connected(
			_on_network_sale_pending
		)
	):
		_network_sale_service.local_sale_pending.connect(
			_on_network_sale_pending
		)
		_network_sale_service.local_sale_finished.connect(
			_on_network_sale_finished
		)
	_fish_selection.clear()
	if not _inventory.catches_changed.is_connected(_on_inventory_changed):
		_inventory.catches_changed.connect(_on_inventory_changed)
	if not _wallet.balance_changed.is_connected(_on_wallet_balance_changed):
		_wallet.balance_changed.connect(_on_wallet_balance_changed)
	if not _fishing_spot.bite_activated.is_connected(_on_bite_activated):
		_fishing_spot.bite_activated.connect(_on_bite_activated)
	if not _bag.contents_changed.is_connected(_on_bag_changed):
		_bag.contents_changed.connect(_on_bag_changed)
	if not _player.active_bait_changed.is_connected(_on_active_bait_changed):
		_player.active_bait_changed.connect(_on_active_bait_changed)
	if not _player.active_lure_changed.is_connected(_on_active_lure_changed):
		_player.active_lure_changed.connect(_on_active_lure_changed)
	if not _hotbar.slots_changed.is_connected(_on_hotbar_changed):
		_hotbar.slots_changed.connect(_on_hotbar_changed)
	if not _cooler_capacity.capacity_changed.is_connected(
		_on_cooler_capacity_changed
	):
		_cooler_capacity.capacity_changed.connect(_on_cooler_capacity_changed)
	_refresh_all()


func setup_controller_mapping(
	mapping_manager: ControllerMappingManagerType,
) -> void:
	_controller_mapping_manager = mapping_manager
	_profile_page.setup_controller_mapping(_controller_mapping_manager)


func set_profile_preview_world_pixel_size(pixel_size: int) -> void:
	_profile_page.set_world_pixel_size(pixel_size)


var _left_page_trigger_held: bool = false
var _right_page_trigger_held: bool = false


func _input(event: InputEvent) -> void:
	if event is InputEventKey and event.echo:
		return
	if _handle_controller_ownership_input(event):
		get_viewport().set_input_as_handled()
		return
	if visible:
		_reserve_main_navigation_for_page_switching()
		_reserve_visible_secondary_navigation()
	if _handle_controller_page_switch(event):
		get_viewport().set_input_as_handled()
		return
	if _handle_controller_secondary_switch(event):
		get_viewport().set_input_as_handled()
		return
	if _handle_direct_page_shortcut(event):
		get_viewport().set_input_as_handled()
		return
	if visible:
		if event.is_action_pressed("open_backpack"):
			if not _transitioning and not _page_transitioning:
				close_menu()
			get_viewport().set_input_as_handled()
			return
	if (
		event.is_action_pressed("open_backpack")
		and _fishing_spot != null
		and _fishing_spot.can_open_player_menu()
	):
		open_menu()
		get_viewport().set_input_as_handled()


func _handle_controller_ownership_input(event: InputEvent) -> bool:
	if not visible:
		return false
	var button_event := event as InputEventJoypadButton
	var accept_pressed: bool = (
		button_event != null
		and button_event.pressed
		and _event_matches_controller_role(
			event,
			ControllerMappingManagerType.ROLE_A,
			JOY_BUTTON_A,
		)
	)
	var cancel_pressed: bool = (
		button_event != null
		and button_event.pressed
		and _event_matches_controller_role(
			event,
			ControllerMappingManagerType.ROLE_B,
			JOY_BUTTON_B,
		)
	)
	var alternate_pressed: bool = (
		button_event != null
		and button_event.pressed
		and _event_matches_controller_role(
			event,
			ControllerMappingManagerType.ROLE_Y,
			JOY_BUTTON_Y,
		)
	)
	if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT:
		if cancel_pressed:
			_release_controller_ownership(true, false)
			return true
		if alternate_pressed:
			if _hotbar != null:
				_hotbar.clear_slot(_hotbar.get_selected_slot())
			return true
		if accept_pressed:
			return true
		return false
	if _controller_ownership == ControllerOwnership.HOTBAR_PLACEMENT:
		if accept_pressed:
			_confirm_controller_hotbar_placement()
			return true
		if cancel_pressed:
			_release_controller_ownership(true, true)
			return true
		if alternate_pressed:
			return true
		return false
	if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS:
		if cancel_pressed:
			_release_controller_ownership(true, false)
			return true
		if alternate_pressed:
			return true
		var direction: int = 0
		if event.is_action_pressed("ui_left") or event.is_action_pressed("ui_up"):
			direction = -1
		elif (
			event.is_action_pressed("ui_right")
			or event.is_action_pressed("ui_down")
		):
			direction = 1
		if direction != 0:
			_focus_next_notepad_action(direction)
			return true
		return false
	if alternate_pressed:
		return _try_begin_controller_hotbar_placement()
	if accept_pressed:
		return _try_enter_notepad_controller_ownership()
	return false


func _event_matches_controller_role(
	event: InputEvent,
	role: StringName,
	fallback_button: JoyButton,
) -> bool:
	if _controller_mapping_manager != null:
		return _controller_mapping_manager.event_matches_role(event, role)
	var button_event := event as InputEventJoypadButton
	return button_event != null and button_event.button_index == fallback_button


func _try_enter_notepad_controller_ownership() -> bool:
	var focus_owner: Control = get_viewport().gui_get_focus_owner()
	if focus_owner == null:
		return false
	var source_identity: StringName
	var actions: Array[BaseButton] = []
	if _current_section == Section.COOLER:
		var fish_node := focus_owner as CoolerFishSpriteType
		if fish_node == null or fish_node.catch_id.is_empty():
			return false
		source_identity = fish_node.catch_id
		_on_catch_card_pressed(source_identity)
		if not _favorite_bubble.disabled:
			actions.append(_favorite_bubble)
		if not _sell_bubble.disabled:
			actions.append(_sell_bubble)
		if not _sell_all_bubble.disabled:
			actions.append(_sell_all_bubble)
	elif _current_section == Section.TACKLE_BOX:
		if not focus_owner.has_meta(&"controller_tackle_item_id"):
			return false
		source_identity = StringName(
			str(focus_owner.get_meta(&"controller_tackle_item_id"))
		)
		_select_tackle_item(source_identity)
		if _tackle_equip_button.visible and not _tackle_equip_button.disabled:
			actions.append(_tackle_equip_button)
	else:
		return false
	if actions.is_empty():
		_restore_controller_item_focus(_current_section, source_identity)
		return true
	_controller_source_section = _current_section
	_controller_source_identity = source_identity
	_controller_notepad_actions = actions
	_controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS
	actions.front().call_deferred("grab_focus")
	return true


func _focus_next_notepad_action(direction: int) -> void:
	var available: Array[BaseButton] = []
	for action: BaseButton in _controller_notepad_actions:
		if (
			is_instance_valid(action)
			and action.visible
			and not action.disabled
			and action.focus_mode != Control.FOCUS_NONE
		):
			available.append(action)
	if available.is_empty():
		return
	var focused: Control = get_viewport().gui_get_focus_owner()
	var current_index: int = available.find(focused)
	if current_index < 0:
		current_index = 0 if direction > 0 else available.size() - 1
	else:
		current_index = wrapi(
			current_index + direction,
			0,
			available.size(),
		)
	available[current_index].grab_focus()


func _try_begin_controller_hotbar_placement() -> bool:
	if _hotbar == null:
		return false
	var focus_owner: Control = get_viewport().gui_get_focus_owner()
	if focus_owner == null:
		return false
	var assignment_kind: PlayerHotbarType.AssignmentKind = (
		PlayerHotbarType.AssignmentKind.EMPTY
	)
	var identity: StringName
	if _current_section == Section.COOLER:
		var fish_node := focus_owner as CoolerFishSpriteType
		if fish_node == null or fish_node.catch_id.is_empty():
			return false
		assignment_kind = PlayerHotbarType.AssignmentKind.FISH
		identity = fish_node.catch_id
	elif _current_section == Section.BAG:
		var item_node := focus_owner as BagItemSpriteType
		if item_node == null or item_node.item_id.is_empty():
			return false
		var item: ItemDataType = (
			_item_catalog.get_item_by_id(item_node.item_id)
			if _item_catalog != null
			else null
		)
		if item == null or not item.hotbar_allowed:
			return true
		assignment_kind = PlayerHotbarType.AssignmentKind.ITEM
		identity = item_node.item_id
	else:
		return false
	_controller_source_section = _current_section
	_controller_source_identity = identity
	_controller_hotbar_assignment_kind = assignment_kind
	_controller_hotbar_identity = identity
	_controller_previous_hotbar_slot = _hotbar.get_selected_slot()
	var initial_slot: int = _find_controller_hotbar_assignment(
		assignment_kind,
		identity,
	)
	if initial_slot < 0:
		initial_slot = _controller_previous_hotbar_slot
	_controller_ownership = ControllerOwnership.HOTBAR_PLACEMENT
	controller_hotbar_placement_requested.emit(
		assignment_kind,
		identity,
		initial_slot,
	)
	return true


func _find_controller_hotbar_assignment(
	assignment_kind: PlayerHotbarType.AssignmentKind,
	identity: StringName,
) -> int:
	for slot_index: int in range(PlayerHotbarType.SLOT_COUNT):
		if (
			assignment_kind == PlayerHotbarType.AssignmentKind.FISH
			and _hotbar.get_fish_catch_id(slot_index) == identity
		):
			return slot_index
		if (
			assignment_kind == PlayerHotbarType.AssignmentKind.ITEM
			and _hotbar.get_item_id(slot_index) == identity
		):
			return slot_index
	return -1


func _confirm_controller_hotbar_placement() -> void:
	if (
		_controller_ownership != ControllerOwnership.HOTBAR_PLACEMENT
		or _hotbar == null
	):
		return
	var slot_index: int = _hotbar.get_selected_slot()
	var assigned: bool = false
	if (
		_controller_hotbar_assignment_kind
		== PlayerHotbarType.AssignmentKind.FISH
	):
		assigned = _hotbar.assign_fish(
			slot_index,
			_controller_hotbar_identity,
		)
	elif (
		_controller_hotbar_assignment_kind
		== PlayerHotbarType.AssignmentKind.ITEM
	):
		assigned = _hotbar.assign_item(
			slot_index,
			_controller_hotbar_identity,
		)
	if assigned:
		_release_controller_ownership(true, false)


func _release_controller_ownership(
	restore_source_focus: bool,
	restore_previous_hotbar_slot: bool,
) -> void:
	if _controller_ownership == ControllerOwnership.ITEM_LIST:
		return
	var prior_ownership: ControllerOwnership = _controller_ownership
	var source_section: Section = _controller_source_section
	var source_identity: StringName = _controller_source_identity
	_controller_ownership = ControllerOwnership.ITEM_LIST
	_controller_notepad_actions.clear()
	_controller_source_identity = StringName()
	_controller_hotbar_assignment_kind = PlayerHotbarType.AssignmentKind.EMPTY
	_controller_hotbar_identity = StringName()
	if prior_ownership == ControllerOwnership.HOTBAR_PLACEMENT:
		if restore_previous_hotbar_slot and _hotbar != null:
			_hotbar.select_slot(_controller_previous_hotbar_slot)
		controller_hotbar_placement_ended.emit()
	if prior_ownership == ControllerOwnership.HOTBAR_MANAGEMENT:
		controller_hotbar_management_ended.emit()
		inventory_hotbar_context_changed.emit(
			_current_section in [Section.COOLER, Section.BAG]
		)
	if restore_source_focus:
		call_deferred(
			"_restore_controller_item_focus",
			source_section,
			source_identity,
		)


func _restore_controller_item_focus(
	section: Section,
	identity: StringName,
) -> void:
	if not visible or section != _current_section:
		return
	if identity.is_empty():
		_focus_current_section()
		return
	var target: Control
	if section == Section.COOLER:
		target = _fish_nodes.get(identity) as CoolerFishSpriteType
	elif section == Section.BAG:
		target = _bag_item_nodes.get(identity) as BagItemSpriteType
	elif section == Section.TACKLE_BOX:
		for child: Node in _tackle_item_list.get_children():
			var button := child as BaseButton
			if (
				button != null
				and button.has_meta(&"controller_tackle_item_id")
				and StringName(str(button.get_meta(
					&"controller_tackle_item_id"
				))) == identity
			):
				target = button
				break
	if (
		target != null
		and is_instance_valid(target)
		and target.is_visible_in_tree()
		and target.focus_mode != Control.FOCUS_NONE
	):
		target.grab_focus()
	else:
		_focus_current_section()


func _handle_controller_page_switch(event: InputEvent) -> bool:
	var button_event: InputEventJoypadButton = event as InputEventJoypadButton
	var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion
	var use_mapping: bool = (
		_controller_mapping_manager != null
	)
	var uses_left_trigger: bool = (
		_controller_mapping_manager.event_uses_role(
			event, ControllerMappingManagerType.ROLE_POINTER_MODIFIER
		)
		if use_mapping
		else (
			motion_event != null
			and motion_event.axis == JOY_AXIS_TRIGGER_LEFT
		)
	)
	var uses_right_trigger: bool = (
		_controller_mapping_manager.event_uses_role(
			event, ControllerMappingManagerType.ROLE_CAMERA_ZOOM
		)
		if use_mapping
		else (
			motion_event != null
			and motion_event.axis == JOY_AXIS_TRIGGER_RIGHT
		)
	)
	if (
		not (uses_left_trigger or uses_right_trigger)
		or not visible
	):
		return false
	var is_pressed: bool = (
		button_event.pressed
		if button_event != null
		else motion_event.axis_value > 0.5
	)
	if not is_pressed:
		if uses_left_trigger:
			_left_page_trigger_held = false
		if uses_right_trigger:
			_right_page_trigger_held = false
		return true
	if (
		(uses_left_trigger and _left_page_trigger_held)
		or (uses_right_trigger and _right_page_trigger_held)
	):
		return true
	if uses_left_trigger:
		_left_page_trigger_held = true
	if uses_right_trigger:
		_right_page_trigger_held = true
	# Trigger input belongs to the Player Menu while it is visible, even when a
	# transition or modal temporarily prevents changing pages.
	if (
		_transitioning
		or _page_transitioning
		or _sale_confirmation.visible
		or get_viewport().gui_is_dragging()
		or _controller_ownership != ControllerOwnership.ITEM_LIST
	):
		return true
	var sections: Array[Section] = [
		_last_inventory_section,
		Section.LOGBOOK,
		Section.NET,
		Section.MAIL,
		Section.PROFILE,
		Section.PLAYERS,
	]
	var current_index: int = 0
	if not _is_inventory_section(_current_section):
		current_index = sections.find(_current_section)
		if current_index < 0:
			current_index = 0
	var direction: int = (
		-1 if uses_left_trigger else 1
	)
	var next_index: int = wrapi(current_index + direction, 0, sections.size())
	_show_section(sections[next_index])
	return true


func _handle_controller_secondary_switch(event: InputEvent) -> bool:
	var button_event := event as InputEventJoypadButton
	if button_event == null or not visible:
		return false
	var use_mapping: bool = _controller_mapping_manager != null
	var uses_left_bumper: bool = (
		_controller_mapping_manager.event_uses_role(
			event, ControllerMappingManagerType.ROLE_LB
		)
		if use_mapping
		else button_event.button_index == JOY_BUTTON_LEFT_SHOULDER
	)
	var uses_right_bumper: bool = (
		_controller_mapping_manager.event_uses_role(
			event, ControllerMappingManagerType.ROLE_RB
		)
		if use_mapping
		else button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER
	)
	if not (uses_left_bumper or uses_right_bumper):
		return false
	if not button_event.pressed:
		return true
	var direction: int = -1 if uses_left_bumper else 1
	if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT:
		_cycle_from_controller_hotbar_management(direction)
		return true
	if (
		_transitioning
		or _page_transitioning
		or _sale_confirmation.visible
		or get_viewport().gui_is_dragging()
		or _controller_ownership != ControllerOwnership.ITEM_LIST
	):
		return true
	if _is_inventory_section(_current_section):
		if (
			(direction > 0 and _current_section == Section.BAG)
			or (direction < 0 and _current_section == Section.COOLER)
		):
			_begin_controller_hotbar_management()
			return true
		var inventory_sections: Array[Section] = [
			Section.COOLER,
			Section.TACKLE_BOX,
			Section.BAG,
		]
		var inventory_index: int = inventory_sections.find(_current_section)
		_show_section(inventory_sections[wrapi(
			inventory_index + direction,
			0,
			inventory_sections.size(),
		)])
		return true
	_cycle_visible_secondary_tabs(direction)
	return true


func _begin_controller_hotbar_management() -> void:
	if _hotbar == null:
		return
	_controller_source_section = _current_section
	_controller_source_identity = StringName()
	var focus_owner: Control = get_viewport().gui_get_focus_owner()
	if _current_section == Section.COOLER:
		var fish_node := focus_owner as CoolerFishSpriteType
		if fish_node != null:
			_controller_source_identity = fish_node.catch_id
	elif _current_section == Section.BAG:
		var item_node := focus_owner as BagItemSpriteType
		if item_node != null:
			_controller_source_identity = item_node.item_id
	_controller_ownership = ControllerOwnership.HOTBAR_MANAGEMENT
	controller_hotbar_management_requested.emit(_hotbar.get_selected_slot())


func _cycle_from_controller_hotbar_management(direction: int) -> void:
	_release_controller_ownership(false, false)
	var target_section: Section = (
		Section.COOLER if direction > 0 else Section.BAG
	)
	if target_section == _current_section:
		call_deferred("_focus_current_section")
	else:
		_show_section(target_section)


func _cycle_visible_secondary_tabs(direction: int) -> bool:
	var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
	var grouped_buttons: Dictionary = {}
	_collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons)
	var selected_group: Variant = null
	var selected_group_y: float = INF
	for group_value: Variant in grouped_buttons.keys():
		var buttons := grouped_buttons[group_value] as Array
		if buttons.size() < 2:
			continue
		var group_y: float = INF
		for item: Variant in buttons:
			var button := item as BaseButton
			group_y = minf(group_y, button.global_position.y)
		if group_y < selected_group_y:
			selected_group = group_value
			selected_group_y = group_y
	if selected_group == null:
		return false
	var tabs := grouped_buttons[selected_group] as Array
	tabs.sort_custom(func(first: BaseButton, second: BaseButton) -> bool:
		return first.global_position.x < second.global_position.x
	)
	var current_index: int = 0
	for index: int in tabs.size():
		var tab := tabs[index] as BaseButton
		if tab.button_pressed:
			current_index = index
			break
	var target := tabs[wrapi(
		current_index + direction, 0, tabs.size()
	)] as BaseButton
	target.set_pressed_no_signal(true)
	target.pressed.emit()
	return true


func _collect_visible_toggle_buttons(
	root: Node,
	navigation_cluster: Control,
	grouped_buttons: Dictionary,
) -> void:
	for child: Node in root.get_children():
		var child_control := child as Control
		if child_control != null and not child_control.is_visible_in_tree():
			continue
		var button := child as BaseButton
		if (
			button != null
			and button.toggle_mode
			and (
				navigation_cluster == null
				or not navigation_cluster.is_ancestor_of(button)
			)
		):
			var group_key: Variant = button.get_parent()
			if button.button_group != null:
				group_key = button.button_group
			if not grouped_buttons.has(group_key):
				grouped_buttons[group_key] = []
			var buttons := grouped_buttons[group_key] as Array
			buttons.append(button)
		_collect_visible_toggle_buttons(
			child, navigation_cluster, grouped_buttons
		)


func _handle_direct_page_shortcut(event: InputEvent) -> bool:
	var key_event := event as InputEventKey
	if (
		key_event == null
		or not key_event.pressed
		or _is_text_input_active()
		or _shortcut_blocked_by_modal_state()
	):
		return false
	var physical_key: Key = key_event.physical_keycode
	var inventory_requested: bool = physical_key == KEY_I
	var logbook_requested: bool = physical_key == KEY_L
	var tackle_requested: bool = (
		physical_key == KEY_V
		or event.is_action_pressed("open_tacklebox")
	)
	if not inventory_requested and not tackle_requested and not logbook_requested:
		return false
	var requested_section: Section = (
		Section.LOGBOOK
		if logbook_requested
		else (
			Section.TACKLE_BOX
			if tackle_requested
			else _last_inventory_section
		)
	)
	if visible:
		if (
			_current_section == requested_section
			and not _transitioning
			and not _page_transitioning
		):
			close_menu()
		elif not _transitioning and not _page_transitioning:
			_show_section(requested_section)
	else:
		if (
			_fishing_spot == null
			or not _fishing_spot.can_open_player_menu()
		):
			return false
		_current_section = requested_section
		open_menu()
	return true


func _shortcut_blocked_by_modal_state() -> bool:
	return (
		_sale_confirmation.visible
		or (
			_current_section == Section.MAIL
			and _mail_page.is_composing_letter()
		)
		or (
			_current_section == Section.PROFILE
			and _profile_page.has_modal_confirmation()
		)
	)


func _is_text_input_active() -> bool:
	var focus_owner: Control = get_viewport().gui_get_focus_owner()
	return (
		focus_owner != null
		and focus_owner.is_visible_in_tree()
		and (focus_owner is LineEdit or focus_owner is TextEdit)
	)


func consume_escape() -> bool:
	if not visible:
		return false
	if _transitioning or _page_transitioning:
		return true
	if _sale_confirmation.visible:
		_close_sale_confirmation()
	elif _current_section == Section.MAIL and _mail_page.consume_escape():
		pass
	elif _current_section == Section.PROFILE and _profile_page.consume_escape():
		pass
	else:
		close_menu()
	return true


func open_menu() -> void:
	if (
		visible
		or _shop_cooler_context_active
		or _player == null
		or _fishing_spot == null
		or not _fishing_spot.can_open_player_menu()
	):
		return
	_release_controller_ownership(false, true)
	_menu_generation += 1
	_transition_generation += 1
	_cancel_presentation_tween()
	_cancel_page_tween()
	_prior_movement_enabled = _player.is_movement_enabled()
	_prior_camera_input_enabled = _player.is_camera_input_enabled()
	_prior_mouse_mode = Input.mouse_mode
	_control_snapshot_stored = true
	_mouse_snapshot_stored = true
	_player.set_movement_enabled(false)
	_player.set_camera_input_enabled(false)
	_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
	Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
	visible = true
	_refresh_all()
	_show_section_immediate(_current_section)
	_update_shell_layout()
	_begin_menu_entry()
	menu_visibility_changed.emit(true)
	inventory_hotbar_context_changed.emit(
		_current_section in [Section.COOLER, Section.BAG]
	)


func open_section(section: Section) -> bool:
	if (
		_shop_cooler_context_active
		or _player == null
		or _fishing_spot == null
		or not _fishing_spot.can_open_player_menu()
	):
		return false
	if visible:
		if _transitioning or _page_transitioning or _sale_confirmation.visible:
			return false
		if section != _current_section:
			_show_section(section)
		return true
	_current_section = section
	open_menu()
	return visible


func mount_shop_cooler(
	host: Control,
	buyer: FishBuyerProfileType,
) -> bool:
	if (
		visible
		or _shop_cooler_context_active
		or host == null
		or not is_instance_valid(host)
		or buyer == null
		or not buyer.is_valid()
		or buyer.id != MAIN_SHOP_BUYER_ID
	):
		return false
	_menu_generation += 1
	_cancel_page_tween()
	_transitioning = false
	_page_transitioning = false
	_sale_buyer_override = buyer
	_shop_cooler_context_active = true
	_current_section = Section.COOLER
	_last_inventory_section = Section.COOLER
	_cooler_page.reparent(host, false)
	_sale_confirmation.reparent(host, false)
	_cooler_page.position = Vector2.ZERO
	_cooler_page.size = DESKTOP_REFERENCE_SIZE
	_cooler_page.scale = Vector2.ONE
	_cooler_page.modulate = Color.WHITE
	_cooler_page.visible = true
	_sale_confirmation.visible = false
	_update_shell_layout()
	_refresh_inventory()
	_set_content_interactive(true)
	_update_cooler_water_mask()
	set_process(true)
	_cooler_sort_option.call_deferred("grab_focus")
	return true


func unmount_shop_cooler() -> void:
	if not _shop_cooler_context_active:
		return
	_close_sale_confirmation()
	_set_content_interactive(false)
	_cooler_page.visible = false
	_cooler_page.reparent(_cooler_original_parent, false)
	_cooler_original_parent.move_child(
		_cooler_page,
		mini(_cooler_original_index, _cooler_original_parent.get_child_count() - 1),
	)
	_sale_confirmation.reparent(_confirmation_original_parent, false)
	_confirmation_original_parent.move_child(
		_sale_confirmation,
		mini(
			_confirmation_original_index,
			_confirmation_original_parent.get_child_count() - 1,
		),
	)
	_sale_buyer_override = null
	_shop_cooler_context_active = false
	_menu_generation += 1
	if not visible:
		set_process(false)


func is_shop_cooler_mounted() -> bool:
	return _shop_cooler_context_active


func cancel_shop_cooler_confirmation() -> void:
	if _shop_cooler_context_active and _sale_confirmation.visible:
		_close_sale_confirmation()


func _focus_shop_cooler() -> void:
	if not _shop_cooler_context_active or not _cooler_page.is_visible_in_tree():
		return
	var focused_node := _fish_nodes.get(
		_fish_selection.get_focused_id()
	) as CoolerFishSpriteType
	if focused_node != null and focused_node.focus_mode != Control.FOCUS_NONE:
		focused_node.grab_focus()
		return
	for fish_catch: FishCatchType in _sorted_catches:
		var fish_node := _fish_nodes.get(
			fish_catch.catch_id
		) as CoolerFishSpriteType
		if fish_node != null and fish_node.focus_mode != Control.FOCUS_NONE:
			fish_node.grab_focus()
			return
	_cooler_sort_option.grab_focus()


func close_menu(
	reason: CloseReason = CloseReason.USER,
	restore_controls: bool = true,
) -> void:
	if not visible:
		return
	if (
		reason == CloseReason.USER
		and _current_section == Section.PROFILE
		and _profile_page.request_close_confirmation()
	):
		return
	if _transitioning:
		if reason != CloseReason.USER:
			_finish_close(reason, restore_controls, _menu_generation)
		return
	_release_controller_ownership(false, true)
	get_viewport().gui_cancel_drag()
	_close_sale_confirmation()
	if reason in [
		CloseReason.BITE_STARTED,
		CloseReason.WATER_RECOVERY,
		CloseReason.SESSION_END,
		CloseReason.TEARDOWN,
	]:
		_fish_selection.clear()
	menu_exit_started.emit()
	_begin_menu_exit(reason, restore_controls)


func close_for_water_recovery() -> void:
	_fish_selection.clear()
	close_menu(CloseReason.WATER_RECOVERY, false)


func close_for_game_menu() -> void:
	close_menu(CloseReason.GAME_MENU)


func close_for_session_end() -> void:
	_fish_selection.clear()
	close_menu(CloseReason.SESSION_END, false)


func _exit_tree() -> void:
	_release_controller_ownership(false, true)
	_cancel_presentation_tween()
	_cancel_page_tween()
	if visible:
		_finish_close(CloseReason.TEARDOWN, false, _menu_generation)
	_fish_selection.clear()
	_menu_generation += 1


func _restore_player_controls(generation: int) -> void:
	if (
		generation != _menu_generation
		or not _control_snapshot_stored
	):
		return
	if _player != null and is_instance_valid(_player):
		_player.set_movement_enabled(_prior_movement_enabled)
		_player.set_camera_input_enabled(_prior_camera_input_enabled)
	_control_snapshot_stored = false


func _apply_mouse_close_policy(reason: CloseReason) -> void:
	if not _mouse_snapshot_stored:
		return
	match reason:
		CloseReason.USER, CloseReason.BITE_STARTED:
			Input.mouse_mode = _prior_mouse_mode
		CloseReason.GAME_MENU:
			# The Game Menu immediately assumes visible pointer ownership.
			Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
		CloseReason.WATER_RECOVERY:
			# Recovery remains authoritative over local input.
			pass
		CloseReason.SESSION_END:
			Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
		CloseReason.TEARDOWN:
			pass
	_mouse_snapshot_stored = false


func _on_bite_activated() -> void:
	if visible:
		close_menu(CloseReason.BITE_STARTED)


func _show_section(section: Section) -> void:
	if (
		section == _current_section
		or _transitioning
		or _page_transitioning
		or _sale_confirmation.visible
		or get_viewport().gui_is_dragging()
	):
		return
	if (
		_current_section == Section.PROFILE
		and _profile_page.request_close_confirmation()
	):
		return
	_release_controller_ownership(false, true)
	_begin_page_transition(section)


func _show_section_immediate(section: Section) -> void:
	_current_section = section
	if _is_inventory_section(section):
		_last_inventory_section = section
	if not is_node_ready():
		return
	_configure_navigation_focus()
	_cooler_page.visible = section == Section.COOLER
	_bag_page.visible = section == Section.BAG
	_tackle_box_page.visible = section == Section.TACKLE_BOX
	_inventory_sub_tabs.visible = _is_inventory_section(section)
	_logbook_page.visible = section == Section.LOGBOOK
	_the_net_page.visible = section == Section.NET
	_mail_page.visible = section == Section.MAIL
	_profile_page.visible = section == Section.PROFILE
	_players_page.visible = section == Section.PLAYERS
	_content_shell.visible = false
	_inventory_section.visible = false
	_bag_section.visible = false
	_content_shell.set_background_visible(true)
	_header.visible = section != Section.COOLER
	_separator.visible = section != Section.COOLER
	if section == Section.COOLER:
		_refresh_inventory()
	else:
		_fish_selection.clear()
		_close_detail_constellation()
	if section != Section.BAG:
		_close_bag_detail()
	else:
		_update_bag_detail()
	_refresh_tackle_box()
	if section != Section.LOGBOOK:
		_cancel_logbook_page_transition(true)
		_catalog_logbook.deactivate()
	else:
		_catalog_logbook.activate()
	if section == Section.NET:
		_the_net_page.activate()
	else:
		_the_net_page.deactivate()
	if section == Section.MAIL:
		_mail_page.activate()
	else:
		_mail_page.deactivate()
	if section == Section.PROFILE:
		_profile_page.activate()
	else:
		_profile_page.deactivate()
	if section == Section.PLAYERS:
		_players_page.activate()
	else:
		_players_page.deactivate()
	_inventory_tab.button_pressed = _is_inventory_section(section)
	_cooler_sub_tab.set_selected(section == Section.COOLER)
	_bag_sub_tab.set_selected(section == Section.BAG)
	_tackle_sub_tab.set_selected(section == Section.TACKLE_BOX)
	_refresh_inventory_organizer_tabs()
	_logbook_tab.button_pressed = section == Section.LOGBOOK
	_the_net_tab.button_pressed = section == Section.NET
	_mail_tab.button_pressed = section == Section.MAIL
	_profile_tab.button_pressed = section == Section.PROFILE
	_players_tab.button_pressed = section == Section.PLAYERS
	_update_navigation_selection()
	_configure_active_page_focus()
	inventory_hotbar_context_changed.emit(
		section in [Section.COOLER, Section.BAG]
	)


func _is_inventory_section(section: Section) -> bool:
	return section in [Section.COOLER, Section.BAG, Section.TACKLE_BOX]


func _refresh_inventory_organizer_tabs() -> void:
	for tab: OrganizerTabType in [
		_cooler_sub_tab,
		_tackle_sub_tab,
		_bag_sub_tab,
	]:
		tab.refresh_state()


func _animate_inventory_tab_entry() -> void:
	if not _is_inventory_section(_current_section):
		return
	var tabs: Array[OrganizerTabType] = [
		_cooler_sub_tab,
		_tackle_sub_tab,
		_bag_sub_tab,
	]
	for index: int in tabs.size():
		tabs[index].animate_entrance(float(index) * 0.025)


func _settle_inventory_tabs_for_close() -> void:
	for tab: OrganizerTabType in [
		_cooler_sub_tab,
		_tackle_sub_tab,
		_bag_sub_tab,
	]:
		tab.settle_for_close()


func _show_last_inventory_section() -> void:
	_show_section(_last_inventory_section)


func _focus_current_section() -> void:
	if _shop_cooler_context_active:
		_focus_shop_cooler()
		return
	_reserve_main_navigation_for_page_switching()
	_reserve_visible_secondary_navigation()
	var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
	var candidates: Array[Control] = []
	_collect_focusable_content(self, navigation_cluster, candidates)
	if candidates.is_empty():
		return
	candidates.sort_custom(func(first: Control, second: Control) -> bool:
		if not is_equal_approx(first.global_position.y, second.global_position.y):
			return first.global_position.y < second.global_position.y
		return first.global_position.x < second.global_position.x
	)
	candidates[0].grab_focus()


func _collect_focusable_content(
	root: Node,
	navigation_cluster: Control,
	output: Array[Control],
) -> void:
	for child: Node in root.get_children():
		if child == navigation_cluster:
			continue
		var control := child as Control
		if control != null and not control.is_visible_in_tree():
			continue
		if (
			control != null
			and control.focus_mode != Control.FOCUS_NONE
			and not control is ScrollBar
		):
			output.append(control)
		_collect_focusable_content(child, navigation_cluster, output)


func _process(delta: float) -> void:
	if visible or _shop_cooler_context_active:
		_motion_elapsed += delta
		if visible:
			_navigation_cluster.advance_motion(delta)
			_apply_navigation_selection_presentation()
		for fish_node: CoolerFishSpriteType in _fish_nodes.values():
			fish_node.advance_presentation(delta, _motion_elapsed)
		if not visible:
			return
		var bag_motion_enabled: bool = (
			not _bag_drag_active and not get_viewport().gui_is_dragging()
		)
		for item_node: BagItemSpriteType in _bag_item_nodes.values():
			item_node.advance_presentation(
				_motion_elapsed,
				bag_motion_enabled,
			)


func _configure_navigation_focus() -> void:
	_reserve_main_navigation_for_page_switching()
	var navigation: Array[BubbleButtonType] = [
		_inventory_tab,
		_logbook_tab,
		_the_net_tab,
		_mail_tab,
		_profile_tab,
		_players_tab,
		_close_button,
	]
	for index: int in navigation.size():
		var bubble: BubbleButtonType = navigation[index]
		var previous: BubbleButtonType = navigation[
			(index - 1 + navigation.size()) % navigation.size()
		]
		var next: BubbleButtonType = navigation[
			(index + 1) % navigation.size()
		]
		bubble.focus_neighbor_left = bubble.get_path_to(previous)
		bubble.focus_neighbor_right = bubble.get_path_to(next)
		bubble.focus_neighbor_top = bubble.focus_neighbor_left
		bubble.focus_neighbor_bottom = bubble.focus_neighbor_right


func _reserve_main_navigation_for_page_switching() -> void:
	var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
	if navigation_cluster == null:
		return
	_set_descendant_focus_disabled(navigation_cluster)


func _set_descendant_focus_disabled(root: Node) -> void:
	for child: Node in root.get_children():
		var control := child as Control
		if control != null:
			control.focus_mode = Control.FOCUS_NONE
		_set_descendant_focus_disabled(child)


func _reserve_visible_secondary_navigation() -> void:
	var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
	var grouped_buttons: Dictionary = {}
	_collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons)
	var selected_tabs: Array = []
	var selected_group_y: float = INF
	for group_value: Variant in grouped_buttons.keys():
		var buttons := grouped_buttons[group_value] as Array
		if buttons.size() < 2:
			continue
		var group_y: float = INF
		for item: Variant in buttons:
			var button := item as BaseButton
			group_y = minf(group_y, button.global_position.y)
		if group_y < selected_group_y:
			selected_tabs = buttons
			selected_group_y = group_y
	var focus_owner: Control = get_viewport().gui_get_focus_owner()
	var displaced_focus: bool = selected_tabs.has(focus_owner)
	for item: Variant in selected_tabs:
		var tab := item as Control
		tab.focus_mode = Control.FOCUS_NONE
	if displaced_focus:
		focus_owner.release_focus()
		call_deferred("_focus_current_section")
	var inventory_tabs: Array[Button] = [
		_cooler_sub_tab,
		_tackle_sub_tab,
		_bag_sub_tab,
	]
	for index: int in inventory_tabs.size():
		var tab: Button = inventory_tabs[index]
		tab.focus_neighbor_left = tab.get_path_to(
			inventory_tabs[maxi(index - 1, 0)]
		)
		tab.focus_neighbor_right = tab.get_path_to(
			inventory_tabs[mini(index + 1, inventory_tabs.size() - 1)]
		)
		tab.focus_neighbor_top = tab.get_path_to(_inventory_tab)
	_inventory_tab.focus_neighbor_bottom = _inventory_tab.get_path_to(
		_inventory_tab_for_section(_last_inventory_section)
	)
	_tackle_sub_tab.focus_neighbor_bottom = _tackle_sub_tab.get_path_to(
		_bait_filter
	)


func _inventory_tab_for_section(section: Section) -> Button:
	match section:
		Section.COOLER:
			return _cooler_sub_tab
		Section.BAG:
			return _bag_sub_tab
		_:
			return _tackle_sub_tab


func _configure_sale_confirmation_focus() -> void:
	_confirm_sale_button.focus_neighbor_left = (
		_confirm_sale_button.get_path_to(_cancel_sale_button)
	)
	_confirm_sale_button.focus_neighbor_right = (
		_confirm_sale_button.focus_neighbor_left
	)
	_confirm_sale_button.focus_neighbor_top = (
		_confirm_sale_button.focus_neighbor_left
	)
	_confirm_sale_button.focus_neighbor_bottom = (
		_confirm_sale_button.focus_neighbor_left
	)
	_cancel_sale_button.focus_neighbor_left = (
		_cancel_sale_button.get_path_to(_confirm_sale_button)
	)
	_cancel_sale_button.focus_neighbor_right = (
		_cancel_sale_button.focus_neighbor_left
	)
	_cancel_sale_button.focus_neighbor_top = (
		_cancel_sale_button.focus_neighbor_left
	)
	_cancel_sale_button.focus_neighbor_bottom = (
		_cancel_sale_button.focus_neighbor_left
	)
	if not _sale_confirmation.visible:
		for button: Button in [_confirm_sale_button, _cancel_sale_button]:
			button.focus_mode = Control.FOCUS_NONE
			button.mouse_filter = Control.MOUSE_FILTER_IGNORE


func _configure_active_page_focus() -> void:
	match _current_section:
		Section.COOLER:
			_configure_cooler_fish_focus()
		Section.BAG:
			_configure_bag_item_focus()
		Section.TACKLE_BOX:
			_bait_filter.grab_focus()
		Section.LOGBOOK:
			_catalog_logbook.focus_initial()
		Section.NET:
			_the_net_page.focus_initial()
		Section.MAIL:
			_mail_page.activate()
		Section.PROFILE:
			_profile_page.activate()


func _apply_inventory_styles() -> void:
	for button: Button in [
		_equipment_filter,
		_consumables_filter,
		_bait_filter,
		_lures_filter,
	]:
		UtilityPageStyle.apply_ocean_button(button)
	for panel: PanelContainer in [_tackle_main_panel]:
		var panel_style := UtilityPageStyle.panel_style()
		panel_style.set_corner_radius_all(INVENTORY_MAIN_CORNER_RADIUS)
		panel.add_theme_stylebox_override(
			"panel",
			panel_style,
		)
	for label: Label in [_tackle_empty]:
		label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
		label.add_theme_color_override(
			"font_color",
			UtilityPageStyle.OCEAN_TEXT_PRIMARY,
		)
	_sale_confirmation.add_theme_stylebox_override(
		"panel",
		UtilityPageStyle.panel_style(),
	)
	_confirmation_message.add_theme_font_override(
		"font",
		UtilityPageStyle.TuffyFont,
	)
	_confirmation_message.add_theme_color_override(
		"font_color",
		UtilityPageStyle.OCEAN_TEXT_PRIMARY,
	)
	UtilityPageStyle.apply_ocean_button(_confirm_sale_button)
	UtilityPageStyle.apply_ocean_button(_cancel_sale_button)


func _set_bag_view(view: BagView) -> void:
	if _bag_view == view:
		return
	_bag_view = view
	_selected_bag_item_id = StringName()
	_refresh_bag()


func _set_tackle_view(view: TackleView) -> void:
	_tackle_view = view
	_selected_tackle_item_id = StringName()
	_refresh_tackle_box()


func _refresh_tackle_box() -> void:
	if not is_node_ready():
		return
	_bait_filter.button_pressed = _tackle_view == TackleView.BAIT
	_lures_filter.button_pressed = _tackle_view == TackleView.LURES
	for child: Node in _tackle_item_list.get_children():
		child.queue_free()
	var matching_items: Array[OwnedItemType] = []
	if _bag != null and _item_catalog != null:
		var available_items: Array[OwnedItemType] = (
			_bag.get_unlocked_bait_items()
			if _tackle_view == TackleView.BAIT
			else _bag.get_all_items()
		)
		for owned: OwnedItemType in available_items:
			var item: ItemDataType = _item_catalog.get_item_by_id(
				owned.item_id
			)
			if item == null:
				continue
			if (
				_tackle_view == TackleView.BAIT
				and item.category == ItemDataType.Category.BAIT
			):
				matching_items.append(owned)
			elif (
				_tackle_view == TackleView.LURES
				and item.category == ItemDataType.Category.LURE
			):
				matching_items.append(owned)
	matching_items.sort_custom(_sort_bag_items)
	for owned: OwnedItemType in matching_items:
		var item: ItemDataType = _item_catalog.get_item_by_id(owned.item_id)
		var row := Button.new()
		row.icon = item.icon
		row.tooltip_text = (
			"%s ×%d" % [item.display_name, owned.quantity]
			if item.is_bait()
			else item.display_name
		)
		var compact_tackle_item: bool = item.is_bait() or item.is_lure()
		if compact_tackle_item:
			row.custom_minimum_size = Vector2(72, 72)
			row.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
			row.expand_icon = item.icon != null
			row.alignment = HORIZONTAL_ALIGNMENT_CENTER
			row.text = "" if item.icon != null else item.display_name
			if item.icon == null:
				row.add_theme_font_size_override("font_size", 12)
		else:
			row.text = "%s  ×%d" % [item.display_name, owned.quantity]
			row.alignment = HORIZONTAL_ALIGNMENT_LEFT
		row.toggle_mode = true
		row.set_meta(&"controller_tackle_item_id", owned.item_id)
		row.button_pressed = owned.item_id == _selected_tackle_item_id
		if compact_tackle_item:
			_apply_tackle_bait_button_style(row)
			if item.is_bait():
				_add_tackle_quantity_badge(row, owned.quantity)
		else:
			UtilityPageStyle.apply_ocean_button(row)
		row.pressed.connect(_select_tackle_item.bind(owned.item_id))
		_tackle_item_list.add_child(row)
	_tackle_empty.text = (
		"No bait collected."
		if _tackle_view == TackleView.BAIT
		else "No lures collected."
	)
	_tackle_empty.visible = matching_items.is_empty()
	_update_tackle_detail()


func _add_tackle_quantity_badge(row: Button, quantity: int) -> void:
	var badge := Panel.new()
	badge.name = "QuantityBadge"
	badge.anchor_left = 1.0
	badge.anchor_right = 1.0
	badge.offset_left = -27.0
	badge.offset_top = 3.0
	badge.offset_right = -3.0
	badge.offset_bottom = 27.0
	badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
	badge.z_index = 2
	var badge_style := StyleBoxFlat.new()
	badge_style.bg_color = Color("0b5558")
	badge_style.set_corner_radius_all(12)
	badge_style.anti_aliasing = false
	badge.add_theme_stylebox_override("panel", badge_style)
	row.add_child(badge)
	var quantity_label := Label.new()
	quantity_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	quantity_label.offset_left = 1.0
	quantity_label.offset_right = 1.0
	quantity_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
	quantity_label.text = str(quantity)
	quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	quantity_label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
	quantity_label.add_theme_font_size_override(
		"font_size",
		13 if quantity >= 100 else 15,
	)
	quantity_label.add_theme_color_override("font_color", Color("e7f5f4"))
	badge.add_child(quantity_label)


func _apply_tackle_bait_button_style(button: Button) -> void:
	var profile := BubbleMenuProfile.new()
	var normal_style: StyleBoxFlat = profile.make_normal_style()
	var hover_style: StyleBoxFlat = profile.make_hover_style()
	var pressed_style: StyleBoxFlat = profile.make_pressed_style()
	var disabled_style: StyleBoxFlat = profile.make_disabled_style()
	for style: StyleBoxFlat in [
		normal_style,
		hover_style,
		pressed_style,
		disabled_style,
	]:
		style.set_corner_radius_all(36)
		style.content_margin_left = 13.5
		style.content_margin_top = 13.5
		style.content_margin_right = 13.5
		style.content_margin_bottom = 13.5
	button.add_theme_stylebox_override("normal", normal_style)
	button.add_theme_stylebox_override("hover", hover_style)
	button.add_theme_stylebox_override("focus", hover_style)
	button.add_theme_stylebox_override("pressed", pressed_style)
	button.add_theme_stylebox_override("disabled", disabled_style)
	button.add_theme_color_override("icon_normal_color", Color.WHITE)
	button.add_theme_color_override("icon_hover_color", Color.WHITE)
	button.add_theme_color_override("icon_focus_color", Color.WHITE)
	button.add_theme_color_override("icon_pressed_color", Color.WHITE)


func _select_tackle_item(item_id: StringName) -> void:
	_selected_tackle_item_id = item_id
	_refresh_tackle_box()


func _update_tackle_detail() -> void:
	var item: ItemDataType = (
		_item_catalog.get_item_by_id(_selected_tackle_item_id)
		if _item_catalog != null and not _selected_tackle_item_id.is_empty()
		else null
	)
	if item == null:
		_tackle_equip_button.visible = false
		_tackle_equip_button.persistent_mark = false
		_tackle_detail_text.text = (
			"Select bait for details."
			if _tackle_view == TackleView.BAIT
			else "Select a lure for details."
		)
		return
	_tackle_equip_button.visible = item.is_bait() or item.is_lure()
	var quantity: int = _bag.get_quantity(item.item_id) if _bag != null else 0
	var assigned_slot: int = -1
	if _hotbar != null:
		for slot_index: int in range(PlayerHotbarType.SLOT_COUNT):
			if _hotbar.get_item_id(slot_index) == item.item_id:
				assigned_slot = slot_index
				break
	var detail_lines: Array[String] = [item.display_name, ""]
	if item.is_bait():
		detail_lines.append("quantity: %d" % quantity)
	if assigned_slot >= 0:
		detail_lines.append("hotbar slot %d" % (assigned_slot + 1))
	detail_lines.append("")
	detail_lines.append(item.description)
	_tackle_detail_text.text = "\n".join(detail_lines)
	if item.is_bait() or item.is_lure():
		var is_equipped: bool = (
			_player != null
			and (
				_player.active_bait_id == item.item_id
				if item.is_bait()
				else _player.active_lure_id == item.item_id
			)
		)
		_tackle_equip_button.text = (
			"dequip %s" % item.display_name
			if is_equipped
			else "equip %s" % item.display_name
		)
		_tackle_equip_button.persistent_mark = is_equipped
		_tackle_equip_button.disabled = quantity <= 0
		_tackle_equip_button.refresh_ink_state()


func _toggle_active_tackle() -> void:
	if _player == null or _item_catalog == null:
		return
	var item: ItemDataType = _item_catalog.get_item_by_id(_selected_tackle_item_id)
	if item == null:
		return
	if item.is_bait():
		if _player.active_bait_id == item.item_id:
			_player.unequip_bait()
		else:
			_player.equip_bait(item)
	elif item.is_lure():
		if _player.active_lure_id == item.item_id:
			_player.unequip_lure()
		else:
			_player.equip_lure(item)
	else:
		return
	_update_tackle_detail()


func _on_active_bait_changed(_item_id: StringName) -> void:
	_update_tackle_detail()


func _on_active_lure_changed(_item_id: StringName) -> void:
	_update_tackle_detail()


func _apply_cooler_control_styles() -> void:
	var profile: BubbleMenuProfile = _inventory_tab.profile
	if profile == null:
		return
	_sort_option.add_theme_stylebox_override(
		"normal",
		profile.make_normal_style(),
	)
	_sort_option.add_theme_stylebox_override(
		"hover",
		profile.make_hover_style(),
	)
	_sort_option.add_theme_stylebox_override(
		"focus",
		profile.make_hover_style(),
	)
	_sort_option.add_theme_stylebox_override(
		"pressed",
		profile.make_pressed_style(),
	)
	_sort_option.add_theme_color_override("font_color", profile.text_color)
	_sort_option.add_theme_color_override(
		"font_hover_color",
		profile.text_hover_color,
	)
	_sort_option.add_theme_color_override(
		"font_focus_color",
		profile.text_hover_color,
	)
func _apply_cooler_wall_styles() -> void:
	var outer := StyleBoxFlat.new()
	outer.bg_color = Color(0.76, 0.9, 0.96, 1.0)
	outer.border_color = Color(0.91, 0.98, 1.0, 1.0)
	outer.set_border_width_all(7)
	outer.set_corner_radius_all(INVENTORY_MAIN_CORNER_RADIUS)
	_cooler_outer_wall.add_theme_stylebox_override("panel", outer)
	var inner := StyleBoxFlat.new()
	inner.bg_color = Color(0.018, 0.16, 0.25, 1.0)
	inner.border_color = Color(0.56, 0.78, 0.86, 1.0)
	inner.set_border_width_all(5)
	inner.set_corner_radius_all(INVENTORY_INNER_CORNER_RADIUS)
	_cooler_inner_liner.add_theme_stylebox_override("panel", inner)
	var scroll_bar := _cooler_scroll.get_v_scroll_bar()
	var scroll_grabber := StyleBoxFlat.new()
	scroll_grabber.bg_color = Color(0.56, 0.78, 0.86, 0.58)
	scroll_grabber.set_corner_radius_all(6)
	scroll_bar.add_theme_stylebox_override("grabber", scroll_grabber)
	scroll_bar.add_theme_stylebox_override("grabber_highlight", scroll_grabber)
	scroll_bar.add_theme_stylebox_override("grabber_pressed", scroll_grabber)
	var scroll_track := StyleBoxFlat.new()
	scroll_track.bg_color = Color(0.02, 0.1, 0.14, 0.28)
	scroll_track.set_corner_radius_all(6)
	scroll_bar.add_theme_stylebox_override("scroll", scroll_track)


func _apply_cooler_notepad_style() -> void:
	_detail_bubble.add_theme_stylebox_override(
		"panel",
		InventoryNotepadType.make_layout_style(false),
	)
	InventoryNotepadType.apply_handwritten_to(_detail_constellation)


func _apply_navigation_styles() -> void:
	var profile: BubbleMenuProfile = _inventory_tab.profile
	if profile == null:
		return
	var normal := _make_navigation_style(profile.normal_fill, 2, 0.24)
	var hover := _make_navigation_style(
		profile.hover_fill.lightened(0.025),
		4,
		0.34,
	)
	var pressed := _make_navigation_style(
		profile.pressed_fill.lightened(0.055),
		5,
		0.40,
	)
	var disabled := _make_navigation_style(profile.disabled_fill, 1, 0.14)
	for bubble: BubbleButtonType in [
		_inventory_tab,
		_logbook_tab,
		_the_net_tab,
		_mail_tab,
		_profile_tab,
		_players_tab,
		_close_button,
	]:
		bubble.add_theme_stylebox_override("normal", normal)
		bubble.add_theme_stylebox_override("hover", hover)
		bubble.add_theme_stylebox_override("focus", hover)
		bubble.add_theme_stylebox_override("pressed", pressed)
		bubble.add_theme_stylebox_override("disabled", disabled)


func _make_navigation_style(
	fill: Color,
	shadow_size: int,
	shadow_alpha: float,
) -> StyleBoxFlat:
	var style := StyleBoxFlat.new()
	style.bg_color = fill
	style.set_corner_radius_all(128)
	style.set_border_width_all(0)
	style.shadow_color = Color(0.015, 0.06, 0.09, shadow_alpha)
	style.shadow_size = shadow_size
	style.shadow_offset = Vector2(0.0, 2.0)
	return style


func _apply_navigation_selection_presentation() -> void:
	for bubble: BubbleButtonType in [
		_inventory_tab,
		_logbook_tab,
		_the_net_tab,
		_mail_tab,
		_profile_tab,
		_players_tab,
	]:
		if not bubble.button_pressed:
			continue
		bubble.scale *= NAVIGATION_SELECTED_SCALE


func _apply_bag_styles() -> void:
	var outer := StyleBoxFlat.new()
	outer.bg_color = UtilityPageStyle.OCEAN_PANEL_DEEP
	outer.set_border_width_all(0)
	outer.set_corner_radius_all(INVENTORY_MAIN_CORNER_RADIUS)
	_bag_outer_wall.add_theme_stylebox_override("panel", outer)
	var inner := StyleBoxFlat.new()
	inner.bg_color = UtilityPageStyle.OCEAN_PANEL_MID
	inner.set_border_width_all(0)
	inner.set_corner_radius_all(INVENTORY_INNER_CORNER_RADIUS)
	_bag_inner_liner.add_theme_stylebox_override("panel", inner)
func _apply_logbook_styles() -> void:
	var backing := StyleBoxFlat.new()
	backing.bg_color = Color(0.54, 0.42, 0.27, 1.0)
	backing.border_color = Color(0.72, 0.59, 0.39, 1.0)
	backing.set_border_width_all(6)
	backing.corner_radius_top_left = 42
	backing.corner_radius_top_right = 34
	backing.corner_radius_bottom_right = 46
	backing.corner_radius_bottom_left = 36
	_book_backing.add_theme_stylebox_override("panel", backing)
	var left_paper := StyleBoxFlat.new()
	left_paper.bg_color = Color(0.95, 0.91, 0.79, 1.0)
	left_paper.border_color = Color(0.80, 0.70, 0.52, 1.0)
	left_paper.set_border_width_all(3)
	left_paper.corner_radius_top_left = 30
	left_paper.corner_radius_bottom_left = 38
	left_paper.corner_radius_top_right = 8
	left_paper.corner_radius_bottom_right = 6
	_left_page.add_theme_stylebox_override("panel", left_paper)
	var right_paper := StyleBoxFlat.new()
	right_paper.bg_color = Color(0.925, 0.875, 0.74, 1.0)
	right_paper.border_color = Color(0.77, 0.66, 0.48, 1.0)
	right_paper.set_border_width_all(3)
	right_paper.corner_radius_top_left = 8
	right_paper.corner_radius_bottom_left = 6
	right_paper.corner_radius_top_right = 34
	right_paper.corner_radius_bottom_right = 40
	_right_page.add_theme_stylebox_override("panel", right_paper)
	for label: Label in [_left_heading, _right_heading, _logbook_empty_state]:
		label.add_theme_color_override(
			"font_color",
			Color(0.22, 0.16, 0.09, 1.0),
		)
	var page_profile: BubbleMenuProfile = _logbook_previous.profile
	if page_profile != null:
		var disabled_style: StyleBoxFlat = page_profile.make_normal_style()
		disabled_style.bg_color.a = 0.82
		disabled_style.border_color.a = 0.72
		for control: BubbleButtonType in [
			_logbook_previous,
			_logbook_next,
		]:
			control.add_theme_stylebox_override(
				"disabled",
				disabled_style.duplicate(),
			)
			control.add_theme_color_override(
				"font_disabled_color",
				Color(0.035, 0.145, 0.22, 0.68),
			)
	_logbook_page_status.set_content("pages", "1 / 1")


func _update_navigation_selection() -> void:
	_set_navigation_target(_current_section)


func _on_mail_unread_count_changed(count: int) -> void:
	_mail_unread_badge.visible = count > 0
	_mail_unread_badge.text = "99+" if count > 99 else str(count)


func _set_navigation_target(section: Section) -> void:
	_inventory_tab.button_pressed = _is_inventory_section(section)
	_logbook_tab.button_pressed = section == Section.LOGBOOK
	_the_net_tab.button_pressed = section == Section.NET
	_mail_tab.button_pressed = section == Section.MAIL
	_profile_tab.button_pressed = section == Section.PROFILE
	_players_tab.button_pressed = section == Section.PLAYERS


func _update_shell_layout() -> void:
	if not is_node_ready():
		return
	var compact: bool = false
	_compact_layout = compact
	var reference_size := DESKTOP_REFERENCE_SIZE
	_presentation_scale_root.size = reference_size
	if not _transitioning:
		_presentation_scale_root.scale = Vector2.ONE
	_presentation_scale_root.position = Vector2.ZERO
	_presentation_scale_root.pivot_offset = reference_size * 0.5
	_content_shell.position = Vector2(14.0, 92.0) if compact else Vector2(42.0, 104.0)
	_content_shell.size = Vector2(612.0, 286.0) if compact else Vector2(1196.0, 478.0)
	_presentation_rest_position = _content_shell.position
	var navigation_size := (
		Vector2(840.0, 75.0) if compact else Vector2(840.0, 100.0)
	)
	_navigation_cluster.position = NAVIGATION_CANONICAL_POSITION
	_navigation_cluster.size = navigation_size
	_navigation_cluster.scale = (
		Vector2.ONE * NAVIGATION_PRESENTATION_SCALE
	)
	_navigation_cluster.apply_layout(navigation_size, compact)
	_cooler_page.size = reference_size
	_cooler_page.position = Vector2.ZERO
	_cooler_rest_position = Vector2.ZERO
	# The Bag shell has the widest 88 px upper-left curve. Eight more pixels
	# place every tab flange behind a straight section of the shared panel.
	_inventory_sub_tabs.position = (
		INVENTORY_MAIN_POSITION
		+ Vector2(INVENTORY_TAB_LEFT_INSET, -30.0)
	)
	_layout_cooler_fish(false)
	_cooler_outer_wall.position = INVENTORY_MAIN_POSITION
	_cooler_outer_wall.size = INVENTORY_MAIN_SIZE
	_detail_constellation.position = INVENTORY_NOTEPAD_POSITION
	_detail_constellation.size = INVENTORY_NOTEPAD_SIZE
	_notepad_binding.position = Vector2(44.0, 1.0) if compact else Vector2(28.0, 4.0)
	_notepad_binding.size = Vector2(218.0, 20.0) if compact else Vector2(232.0, 27.0)
	_notepad_binding.add_theme_font_size_override("font_size", 13 if compact else 20)
	_notepad_title.position = Vector2(10.0, 14.0) if compact else Vector2(20.0, 31.0)
	_notepad_title.size = Vector2(286.0, 18.0) if compact else Vector2(248.0, 28.0)
	_notepad_title.add_theme_font_size_override("font_size", 14 if compact else 20)
	_notepad_rule.position = Vector2(10.0, 49.0) if compact else Vector2(18.0, 87.0)
	_notepad_rule.size = Vector2(286.0, 2.0) if compact else Vector2(252.0, 2.0)
	_cooler_sort_controls.position = (
		Vector2(10.0, 51.0) if compact else Vector2(18.0, 98.0)
	)
	_cooler_sort_controls.size = (
		Vector2(286.0, 31.0) if compact else Vector2(252.0, 38.0)
	)
	_cooler_sort_controls.get_child(0).visible = false
	_cooler_sort_option.custom_minimum_size = (
		Vector2(140.0, 31.0) if compact else Vector2(123.0, 38.0)
	)
	_cooler_sort_direction.custom_minimum_size = (
		Vector2(140.0, 31.0) if compact else Vector2(123.0, 38.0)
	)
	_cooler_sort_option.set_choice_font_size(12 if compact else 15)
	_cooler_sort_direction.add_theme_font_size_override(
		"font_size",
		12 if compact else 14,
	)
	_notepad_wallet_value.position = (
		Vector2(10.0, 33.0) if compact else Vector2(18.0, 61.0)
	)
	_notepad_wallet_value.size = (
		Vector2(138.0, 17.0) if compact else Vector2(120.0, 23.0)
	)
	_notepad_capacity_value.position = (
		Vector2(154.0, 33.0) if compact else Vector2(146.0, 61.0)
	)
	_notepad_capacity_value.size = (
		Vector2(142.0, 17.0) if compact else Vector2(124.0, 23.0)
	)
	for status_value: Label in [
		_notepad_wallet_value,
		_notepad_capacity_value,
	]:
		status_value.add_theme_font_size_override(
			"font_size",
			10 if compact else 14,
		)
	_cooler_detail_texture.position = (
		Vector2(5.0, 84.0) if compact else Vector2(80.0, 137.0)
	)
	_cooler_detail_texture.size = (
		Vector2(74.0, 58.0) if compact else Vector2(128.0, 100.0)
	)
	_cooler_detail_name.position = (
		Vector2(82.0, 82.0) if compact else Vector2(18.0, 241.0)
	)
	_cooler_detail_name.size = (
		Vector2(214.0, 26.0) if compact else Vector2(252.0, 32.0)
	)
	_cooler_detail_name.add_theme_font_size_override(
		"font_size", 18 if compact else 25,
	)
	_cooler_detail_stats.position = (
		Vector2(82.0, 109.0) if compact else Vector2(18.0, 274.0)
	)
	_cooler_detail_stats.size = (
		Vector2(214.0, 39.0) if compact else Vector2(252.0, 65.0)
	)
	_layout_cooler_detail_text(compact)
	_cooler_selection_summary.position = (
		Vector2(10.0, 151.0) if compact else Vector2(18.0, 346.0)
	)
	_cooler_selection_summary.size = (
		Vector2(130.0, 35.0) if compact else Vector2(252.0, 40.0)
	)
	_layout_cooler_selection_text(compact)
	_favorite_bubble.custom_minimum_size = (
		Vector2(72.0, 42.0) if compact else Vector2(108.0, 50.0)
	)
	_sell_bubble.custom_minimum_size = (
		Vector2(74.0, 42.0) if compact else Vector2(118.0, 50.0)
	)
	_sell_all_bubble.custom_minimum_size = (
		Vector2(96.0, 38.0) if compact else Vector2(122.0, 44.0)
	)
	_favorite_bubble.position = (
		Vector2(144.0, 148.0) if compact else Vector2(26.0, 378.0)
	)
	_favorite_bubble.size = (
		Vector2(72.0, 42.0) if compact else Vector2(108.0, 50.0)
	)
	_sell_bubble.position = (
		Vector2(222.0, 148.0) if compact else Vector2(144.0, 378.0)
	)
	_sell_bubble.size = (
		Vector2(74.0, 42.0) if compact else Vector2(118.0, 50.0)
	)
	_sell_all_bubble.position = (
		Vector2(102.0, 190.0) if compact else Vector2(78.0, 426.0)
	)
	_sell_all_bubble.size = (
		Vector2(96.0, 38.0) if compact else Vector2(122.0, 44.0)
	)
	_favorite_bubble.add_theme_font_size_override(
		"font_size", 11 if compact else 17,
	)
	_sell_bubble.add_theme_font_size_override(
		"font_size", 11 if compact else 17,
	)
	_sell_all_bubble.add_theme_font_size_override(
		"font_size", 11 if compact else 15,
	)
	_update_sort_direction_text()
	_bag_page.size = reference_size
	_bag_page.position = Vector2.ZERO
	_bag_rest_position = Vector2.ZERO
	_tackle_box_page.size = reference_size
	_tackle_box_page.position = Vector2.ZERO
	_tackle_rest_position = Vector2.ZERO
	_bag_outer_wall.position = INVENTORY_MAIN_POSITION
	_bag_outer_wall.size = INVENTORY_MAIN_SIZE
	_bag_filter_tabs.position = (
		INVENTORY_MAIN_POSITION + INVENTORY_HEADER_INSET
	)
	_bag_filter_tabs.size = Vector2(320.0, INVENTORY_HEADER_HEIGHT)
	_bag_host.custom_minimum_size = (
		Vector2(520.0, 152.0) if compact else Vector2(788.0, 358.0)
	)
	_bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
	_bag_detail_constellation.position = INVENTORY_NOTEPAD_POSITION
	_bag_detail_constellation.size = INVENTORY_NOTEPAD_SIZE
	_bag_sprite_detail_texture.custom_minimum_size = (
		Vector2(72.0, 48.0) if compact else Vector2(100.0, 72.0)
	)
	_bag_sprite_detail_name.add_theme_font_size_override(
		"font_size",
		18 if compact else 22,
	)
	_bag_sprite_detail_data.add_theme_font_size_override(
		"font_size",
		12 if compact else 15,
	)
	_tackle_main_panel.position = INVENTORY_MAIN_POSITION
	_tackle_main_panel.size = INVENTORY_MAIN_SIZE
	_tackle_detail_panel.position = INVENTORY_NOTEPAD_POSITION
	_tackle_detail_panel.size = INVENTORY_NOTEPAD_SIZE
	_logbook_page.size = reference_size
	_logbook_page.position = Vector2.ZERO
	_logbook_rest_position = Vector2.ZERO
	_the_net_page.set_anchors_preset(Control.PRESET_TOP_LEFT)
	_the_net_page.size = reference_size
	_the_net_page.position = Vector2.ZERO
	_the_net_rest_position = Vector2.ZERO
	_mail_page.set_anchors_preset(Control.PRESET_TOP_LEFT)
	_mail_page.size = reference_size
	_mail_page.position = Vector2.ZERO
	_mail_rest_position = Vector2.ZERO
	_profile_page.set_anchors_preset(Control.PRESET_TOP_LEFT)
	_profile_page.position = Vector2.ZERO
	_profile_page.size = reference_size
	_profile_rest_position = Vector2.ZERO
	_players_page.set_anchors_preset(Control.PRESET_TOP_LEFT)
	_players_page.position = Vector2.ZERO
	_players_page.size = reference_size
	_sale_confirmation.size = SALE_CONFIRMATION_SIZE
	_sale_confirmation.position = (
		(reference_size - SALE_CONFIRMATION_SIZE) * 0.5
	)
	_cooler_host.custom_minimum_size = Vector2(
		520.0 if compact else 800.0,
		512.0 if compact else 420.0,
	)
	_bag_grid.columns = 2 if compact else 3
	_bag_list.custom_minimum_size.x = 300.0 if compact else 360.0
	_bag_detail.custom_minimum_size.x = 176.0 if compact else 220.0
	_content_stage.custom_minimum_size.y = 220.0 if compact else 260.0
	_content_rest_position = _content_stage.position
	if not _transitioning:
		_content_shell.position = _presentation_rest_position
		_cooler_page.position = _cooler_rest_position
		_bag_page.position = _bag_rest_position
		_tackle_box_page.position = _tackle_rest_position
		_logbook_page.position = _logbook_rest_position
		_the_net_page.position = _the_net_rest_position
		_mail_page.position = _mail_rest_position
		_profile_page.position = _profile_rest_position
		_players_page.position = Vector2.ZERO
		_cooler_page.modulate.a = 1.0
		_bag_page.modulate.a = 1.0
		_tackle_box_page.modulate.a = 1.0
		_logbook_page.modulate.a = 1.0
		_the_net_page.modulate.a = 1.0
		_mail_page.modulate.a = 1.0
		_profile_page.modulate.a = 1.0
		_players_page.modulate.a = 1.0
	if not _page_transitioning:
		_content_stage.position = _content_rest_position
	_layout_cooler_fish(false)
	_layout_bag_items()


func _layout_cooler_detail_text(compact: bool) -> void:
	var weight_row_height: float = 17.0 if compact else 24.0
	_cooler_weight_row.position = Vector2.ZERO
	_cooler_weight_row.size = Vector2(
		_cooler_detail_stats.size.x,
		weight_row_height,
	)
	_cooler_offer_row.position = Vector2(
		0.0,
		17.0 if compact else 27.0,
	)
	_cooler_offer_row.size = Vector2(
		_cooler_detail_stats.size.x,
		22.0 if compact else 30.0,
	)
	_cooler_weight_value.add_theme_font_size_override(
		"font_size",
		9 if compact else 12,
	)
	_cooler_weight_unit.add_theme_font_size_override(
		"font_size",
		10 if compact else 14,
	)
	_cooler_offer_label.add_theme_font_size_override(
		"font_size",
		13 if compact else 18,
	)
	_cooler_offer_value.add_theme_font_size_override(
		"font_size",
		10 if compact else 15,
	)


func _layout_cooler_selection_text(compact: bool) -> void:
	var row_height: float = 17.5 if compact else 20.0
	for numeric_label: Label in [
		_cooler_selected_count_value,
		_cooler_combined_offer_value,
	]:
		numeric_label.add_theme_font_size_override(
			"font_size",
			10 if compact else 14,
		)
	for word_label: Label in [
		_cooler_selection_empty,
		_cooler_selected_count_label,
		_cooler_combined_offer_label,
	]:
		word_label.add_theme_font_size_override(
			"font_size",
			12 if compact else 18,
		)
	_cooler_selected_count_row.position = Vector2.ZERO
	_cooler_selected_count_row.size = Vector2(
		_cooler_selection_summary.size.x,
		row_height,
	)
	_cooler_combined_offer_row.position = Vector2(0.0, row_height)
	_cooler_combined_offer_row.size = Vector2(
		_cooler_selection_summary.size.x,
		row_height,
	)


func _begin_menu_entry() -> void:
	_transitioning = true
	_set_shell_interactive(false)
	_reset_page_transition_visuals()
	_animate_inventory_tab_entry()
	set_process(true)
	var generation: int = _transition_generation
	_presentation_scale_root.pivot_offset = (
		_presentation_scale_root.size * 0.5
	)
	_presentation_scale_root.modulate.a = 0.0
	_presentation_scale_root.scale = (
		Vector2.ONE * UIMotion.PLAYER_MENU_ENTER_SCALE
	)
	_presentation_tween = create_tween().set_parallel(true)
	_presentation_tween.tween_property(
		_presentation_scale_root,
		"modulate:a",
		1.0,
		UIMotion.PLAYER_MENU_ENTER_DURATION,
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
	_presentation_tween.tween_property(
		_presentation_scale_root,
		"scale",
		Vector2.ONE,
		UIMotion.PLAYER_MENU_ENTER_DURATION,
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
	_presentation_tween.chain().tween_callback(
		_finish_menu_entry.bind(generation)
	)


func _finish_menu_entry(generation: int) -> void:
	if generation != _transition_generation or not visible:
		return
	_presentation_tween = null
	_transitioning = false
	_set_shell_interactive(true)
	_focus_current_section()


func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void:
	if reason != CloseReason.USER:
		_finish_close(reason, restore_controls, _menu_generation)
		return
	_transition_generation += 1
	_cancel_presentation_tween()
	_transitioning = true
	_set_shell_interactive(false)
	_settle_inventory_tabs_for_close()
	if _current_section == Section.LOGBOOK:
		_catalog_logbook.deactivate()
	elif _current_section == Section.NET:
		_the_net_page.deactivate()
	var current_viewport: Viewport = get_viewport()
	if current_viewport != null:
		current_viewport.gui_release_focus()
	var generation: int = _transition_generation
	var closing_generation: int = _menu_generation
	_presentation_tween = create_tween().set_parallel(true)
	_presentation_tween.tween_property(
		_presentation_scale_root,
		"modulate:a",
		0.0,
		UIMotion.PLAYER_MENU_EXIT_DURATION,
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
	_presentation_tween.tween_property(
		_presentation_scale_root,
		"scale",
		Vector2.ONE * UIMotion.PLAYER_MENU_EXIT_SCALE,
		UIMotion.PLAYER_MENU_EXIT_DURATION,
	).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
	_presentation_tween.chain().tween_callback(
		_finish_menu_exit.bind(
			generation,
			reason,
			restore_controls,
			closing_generation,
		)
	)


func _finish_menu_exit(
	generation: int,
	reason: CloseReason,
	restore_controls: bool,
	closing_generation: int,
) -> void:
	if generation != _transition_generation or not visible:
		return
	_presentation_tween = null
	_finish_close(reason, restore_controls, closing_generation)


func _finish_close(
	reason: CloseReason,
	restore_controls: bool,
	closing_generation: int,
) -> void:
	_cancel_presentation_tween()
	_cancel_page_tween()
	_cancel_logbook_page_transition(true)
	_the_net_page.deactivate()
	_transitioning = false
	_page_transitioning = false
	_bag_drag_active = false
	_reset_page_transition_visuals()
	_presentation_scale_root.modulate.a = 1.0
	_presentation_scale_root.scale = Vector2.ONE
	visible = false
	set_process(false)
	var current_viewport: Viewport = get_viewport()
	if current_viewport != null:
		current_viewport.gui_release_focus()
	if _fishing_spot != null and is_instance_valid(_fishing_spot):
		_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
	if restore_controls:
		_restore_player_controls(closing_generation)
	else:
		_control_snapshot_stored = false
	_apply_mouse_close_policy(reason)
	_menu_generation += 1
	_transition_generation += 1
	menu_visibility_changed.emit(false)


func _get_section_root(section: Section) -> Control:
	match section:
		Section.COOLER:
			return _cooler_page
		Section.BAG:
			return _bag_page
		Section.TACKLE_BOX:
			return _tackle_box_page
		Section.LOGBOOK:
			return _logbook_page
		Section.NET:
			return _the_net_page
		Section.MAIL:
			return _mail_page
		Section.PROFILE:
			return _profile_page
		_:
			return _players_page


func _get_section_rest_position(section: Section) -> Vector2:
	match section:
		Section.COOLER:
			return _cooler_rest_position
		Section.BAG:
			return _bag_rest_position
		Section.TACKLE_BOX:
			return _tackle_rest_position
		Section.LOGBOOK:
			return _logbook_rest_position
		Section.NET:
			return _the_net_rest_position
		Section.MAIL:
			return _mail_rest_position
		Section.PROFILE:
			return _profile_rest_position
		_:
			return Vector2.ZERO


func _begin_page_transition(section: Section) -> void:
	_cancel_logbook_page_transition(true)
	_page_transition_generation += 1
	_cancel_page_tween()
	_page_transitioning = true
	_set_content_interactive(false)
	var current_viewport: Viewport = get_viewport()
	if current_viewport != null:
		current_viewport.gui_release_focus()
	var outgoing_section: Section = _current_section
	var outgoing_inventory: bool = _is_inventory_section(outgoing_section)
	var incoming_inventory: bool = _is_inventory_section(section)
	if outgoing_inventory and not incoming_inventory:
		_settle_inventory_tabs_for_close()
	_set_navigation_target(section)
	var generation: int = _page_transition_generation
	_page_outgoing_content_root = _get_section_root(outgoing_section)
	_page_outgoing_root = (
		_inventory_transition_group
		if outgoing_inventory and not incoming_inventory
		else _page_outgoing_content_root
	)
	_page_incoming_root = (
		_inventory_transition_group
		if incoming_inventory and not outgoing_inventory
		else _get_section_root(section)
	)
	var outgoing_rest: Vector2 = _get_section_rest_position(outgoing_section)
	var incoming_rest: Vector2 = _get_section_rest_position(section)
	_show_section_immediate(section)
	if outgoing_inventory and not incoming_inventory:
		# Immediate page selection hides Inventory. Restore the outgoing content
		# beneath its still-opaque masking group for the coordinated fade-out.
		_page_outgoing_content_root.visible = true
		_inventory_sub_tabs.visible = true
	_inventory_transition_group.visible = true
	_page_outgoing_root.visible = true
	_page_incoming_root.visible = true
	_page_outgoing_root.position = outgoing_rest
	_page_incoming_root.position = incoming_rest
	_page_outgoing_root.modulate.a = 1.0
	_page_incoming_root.modulate.a = 0.0
	if incoming_inventory and not outgoing_inventory:
		_animate_inventory_tab_entry()
	var duration: float = (
		UIMotion.PLAYER_MENU_INVENTORY_DURATION
		if outgoing_inventory and incoming_inventory
		else UIMotion.PLAYER_MENU_PAGE_DURATION
	)
	_page_tween = create_tween().set_parallel(true)
	_page_tween.tween_property(
		_page_outgoing_root,
		"modulate:a",
		0.0,
		duration,
	).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
	_page_tween.tween_property(
		_page_incoming_root,
		"modulate:a",
		1.0,
		duration,
	).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
	_page_tween.finished.connect(
		_finish_page_transition.bind(generation),
		CONNECT_ONE_SHOT
	)


func _finish_page_transition(generation: int) -> void:
	if generation != _page_transition_generation or not visible:
		return
	_page_tween = null
	_page_transitioning = false
	if (
		_page_outgoing_root != null
		and _page_outgoing_root != _page_incoming_root
		and _page_outgoing_root != _inventory_transition_group
	):
		_page_outgoing_root.visible = false
	if (
		_page_outgoing_content_root != null
		and not _is_inventory_section(_current_section)
	):
		_page_outgoing_content_root.visible = false
	_inventory_sub_tabs.visible = _is_inventory_section(_current_section)
	_inventory_sub_tabs.modulate.a = 1.0
	_reset_page_transition_visuals()
	_set_content_interactive(true)
	_focus_current_section()


func _set_shell_interactive(interactive: bool) -> void:
	_set_content_interactive(interactive)
	for bubble: BubbleButtonType in [
		_inventory_tab,
		_logbook_tab,
		_the_net_tab,
		_mail_tab,
		_profile_tab,
		_players_tab,
		_close_button,
	]:
		bubble.focus_mode = Control.FOCUS_ALL if interactive else Control.FOCUS_NONE
		bubble.mouse_filter = (
			Control.MOUSE_FILTER_STOP
			if interactive
			else Control.MOUSE_FILTER_IGNORE
		)


func _set_content_interactive(interactive: bool) -> void:
	_content_stage.mouse_filter = (
		Control.MOUSE_FILTER_PASS
		if interactive
		else Control.MOUSE_FILTER_IGNORE
	)
	_cooler_page.mouse_filter = (
		Control.MOUSE_FILTER_PASS
		if interactive and _current_section == Section.COOLER
		else Control.MOUSE_FILTER_IGNORE
	)
	_bag_page.mouse_filter = (
		Control.MOUSE_FILTER_PASS
		if interactive and _current_section == Section.BAG
		else Control.MOUSE_FILTER_IGNORE
	)
	_tackle_box_page.mouse_filter = (
		Control.MOUSE_FILTER_PASS
		if interactive and _current_section == Section.TACKLE_BOX
		else Control.MOUSE_FILTER_IGNORE
	)
	for tab: Button in [
		_cooler_sub_tab, _bag_sub_tab, _tackle_sub_tab,
	]:
		var tab_interactive: bool = (
			interactive and _is_inventory_section(_current_section)
		)
		tab.focus_mode = (
			Control.FOCUS_ALL if tab_interactive else Control.FOCUS_NONE
		)
		tab.mouse_filter = (
			Control.MOUSE_FILTER_STOP
			if tab_interactive and not tab.button_pressed
			else Control.MOUSE_FILTER_IGNORE
		)
	_logbook_page.mouse_filter = (
		Control.MOUSE_FILTER_PASS
		if interactive and _current_section == Section.LOGBOOK
		else Control.MOUSE_FILTER_IGNORE
	)
	_catalog_logbook.set_interactive(
		interactive and _current_section == Section.LOGBOOK
	)
	_the_net_page.set_interactive(
		interactive and _current_section == Section.NET
	)
	_mail_page.set_interactive(
		interactive and _current_section == Section.MAIL
	)
	_profile_page.set_interactive(
		interactive and _current_section == Section.PROFILE
	)
	var cooler_interactive: bool = (
		interactive and _current_section == Section.COOLER
	)
	_cooler_sort_option.disabled = not cooler_interactive
	_cooler_sort_direction.disabled = not cooler_interactive
	_cooler_sort_option.refresh_ink_state()
	_cooler_sort_direction.refresh_ink_state()
	for fish_node: CoolerFishSpriteType in _fish_nodes.values():
		fish_node.focus_mode = (
			Control.FOCUS_ALL
			if cooler_interactive
			else Control.FOCUS_NONE
		)
		fish_node.mouse_filter = (
			Control.MOUSE_FILTER_STOP
			if cooler_interactive
			else Control.MOUSE_FILTER_IGNORE
		)
	var detail_interactive: bool = (
		cooler_interactive and _detail_constellation.visible
	)
	for action: NotepadInkActionType in [
		_favorite_bubble,
		_sell_bubble,
		_sell_all_bubble,
	]:
		var action_interactive: bool = detail_interactive and not action.disabled
		action.focus_mode = (
			Control.FOCUS_ALL
			if action_interactive
			else Control.FOCUS_NONE
		)
		action.mouse_filter = (
			Control.MOUSE_FILTER_STOP
			if action_interactive
			else Control.MOUSE_FILTER_IGNORE
		)
		action.refresh_ink_state()
	var bag_interactive: bool = (
		interactive
		and _current_section == Section.BAG
		and not _bag_drag_active
	)
	for item_node: BagItemSpriteType in _bag_item_nodes.values():
		item_node.focus_mode = (
			Control.FOCUS_ALL if bag_interactive else Control.FOCUS_NONE
		)
		item_node.mouse_filter = (
			Control.MOUSE_FILTER_STOP
			if bag_interactive
			else Control.MOUSE_FILTER_IGNORE
		)


func _cancel_presentation_tween() -> void:
	if _presentation_tween != null:
		_presentation_tween.kill()
		_presentation_tween = null


func _cancel_page_tween() -> void:
	if _page_tween != null:
		_page_tween.kill()
		_page_tween = null
	_reset_page_transition_visuals()


func _reset_page_transition_visuals() -> void:
	for section: Section in [
		Section.COOLER,
		Section.BAG,
		Section.TACKLE_BOX,
		Section.LOGBOOK,
		Section.NET,
		Section.MAIL,
		Section.PROFILE,
		Section.PLAYERS,
	]:
		var page: Control = _get_section_root(section)
		page.position = _get_section_rest_position(section)
		page.modulate.a = 1.0
	if _inventory_transition_group != null:
		_inventory_transition_group.modulate.a = 1.0
		_inventory_transition_group.visible = true
	_inventory_sub_tabs.modulate.a = 1.0
	_page_outgoing_root = null
	_page_incoming_root = null
	_page_outgoing_content_root = null


func _on_sort_selected(index: int, source: OptionButton) -> void:
	var selected_id: int = source.get_item_id(index)
	if selected_id < SortMode.CATCH_ORDER or selected_id > SortMode.RARITY:
		return
	_sort_mode = selected_id as SortMode
	_sort_option.select(_sort_mode)
	_cooler_sort_option.select(_sort_mode)
	_refresh_inventory()


func _on_cooler_sort_selected(sort_id: int) -> void:
	_sort_mode = sort_id as SortMode
	_sort_option.select(_sort_mode)
	_cooler_sort_option.select(_sort_mode)
	_refresh_inventory()


func _on_sort_direction_pressed() -> void:
	_sort_descending = not _sort_descending
	_update_sort_direction_text()
	_refresh_inventory()


func _update_sort_direction_text() -> void:
	var direction_text: String
	match _sort_mode:
		SortMode.CATCH_ORDER:
			if _compact_layout:
				direction_text = "newest" if _sort_descending else "oldest"
			else:
				direction_text = (
					"newest first" if _sort_descending else "oldest first"
				)
		SortMode.NAME:
			direction_text = "z–a" if _sort_descending else "a–z"
		SortMode.RARITY:
			if _compact_layout:
				direction_text = "high–low" if _sort_descending else "low–high"
			else:
				direction_text = (
					"high to low" if _sort_descending else "low to high"
				)
	_sort_direction.text = direction_text
	_cooler_sort_direction.text = direction_text


func _refresh_all() -> void:
	_refresh_economy_summary()
	_refresh_inventory()
	_refresh_bag()


func _on_bag_changed() -> void:
	_refresh_bag()
	_refresh_tackle_box()


func _on_hotbar_changed() -> void:
	_update_bag_detail()
	_update_tackle_detail()


func _refresh_bag() -> void:
	if not is_node_ready():
		return
	var owned_items: Array[OwnedItemType] = (
		_bag.get_all_items() if _bag != null else []
	)
	var filtered_items: Array[OwnedItemType] = []
	for owned: OwnedItemType in owned_items:
		var item: ItemDataType = (
			_item_catalog.get_item_by_id(owned.item_id)
			if _item_catalog != null
			else null
		)
		if item == null:
			continue
		var is_consumable: bool = (
			item.category == ItemDataType.Category.CONSUMABLE
		)
		if (
			(_bag_view == BagView.CONSUMABLES and is_consumable)
			or (
				_bag_view == BagView.EQUIPMENT
				and not is_consumable
				and item.category != ItemDataType.Category.BAIT
				and item.category != ItemDataType.Category.LURE
			)
		):
			filtered_items.append(owned)
	owned_items = filtered_items
	owned_items.sort_custom(_sort_bag_items)
	_sorted_bag_items = owned_items
	_equipment_filter.button_pressed = _bag_view == BagView.EQUIPMENT
	_consumables_filter.button_pressed = (
		_bag_view == BagView.CONSUMABLES
	)
	_bag_empty.visible = owned_items.is_empty()
	_bag_empty_state.visible = owned_items.is_empty()
	_bag_empty_state.text = (
		"No equipment in your Bag."
		if _bag_view == BagView.EQUIPMENT
		else "No consumables in your Bag."
	)
	if (
		not _selected_bag_item_id.is_empty()
		and (_bag == null or not _bag.owns_item(_selected_bag_item_id))
	):
		_selected_bag_item_id = StringName()
	_sync_bag_item_nodes(owned_items)
	_update_bag_detail()


func _sync_bag_item_nodes(owned_items: Array[OwnedItemType]) -> void:
	var retained: Dictionary[StringName, bool] = {}
	for owned: OwnedItemType in owned_items:
		var item: ItemDataType = _item_catalog.get_item_by_id(owned.item_id)
		if item == null:
			continue
		retained[owned.item_id] = true
		var item_node := _bag_item_nodes.get(
			owned.item_id
		) as BagItemSpriteType
		if item_node == null:
			item_node = BagItemSpriteScene.instantiate()
			_bag_item_field.add_child(item_node)
			_bag_item_nodes[owned.item_id] = item_node
			item_node.pressed.connect(
				_select_bag_item.bind(owned.item_id)
			)
			item_node.focus_entered.connect(
				_select_bag_item.bind(owned.item_id)
			)
			item_node.drag_started.connect(_on_bag_drag_started)
			item_node.drag_finished.connect(_on_bag_drag_finished)
		var identity_hash: int = absi(String(owned.item_id).hash())
		item_node.configure_item(
			item.item_id,
			item.display_name,
			item.icon,
			owned.quantity,
			item.hotbar_allowed,
			float(identity_hash % 628) / 100.0,
			0.97 + float(identity_hash % 7) * 0.01,
		)
		item_node.tooltip_text = (
			"drag to a hotbar slot."
			if item.hotbar_allowed
			else "this item cannot be assigned to the hotbar."
		)
		item_node.disabled = false
		item_node.set_selected(item.item_id == _selected_bag_item_id)
	for item_id: StringName in _bag_item_nodes.keys():
		if retained.has(item_id):
			continue
		var removed := _bag_item_nodes[item_id] as BagItemSpriteType
		_bag_item_nodes.erase(item_id)
		_release_focus_from(removed, _bag_sub_tab)
		removed.queue_free()
	_layout_bag_items()
	_configure_bag_item_focus()


func _layout_bag_items() -> void:
	if not is_node_ready() or _sorted_bag_items.is_empty():
		return
	_bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
	var columns: int = 3
	var cell_size := (
		Vector2(166.0, 72.0)
		if _compact_layout
		else Vector2(246.0, 154.0)
	)
	var item_size := (
		Vector2(86.0, 62.0)
		if _compact_layout
		else Vector2(138.0, 102.0)
	)
	var origin := (
		Vector2(16.0, 4.0)
		if _compact_layout
		else Vector2(54.0, 44.0)
	)
	_bag_item_field.custom_minimum_size = (
		Vector2(520.0, 140.0)
		if _compact_layout
		else Vector2(820.0, 240.0)
	)
	for index: int in _sorted_bag_items.size():
		var owned: OwnedItemType = _sorted_bag_items[index]
		var item_node := _bag_item_nodes.get(
			owned.item_id
		) as BagItemSpriteType
		if item_node == null:
			continue
		var identity_hash: int = absi(String(owned.item_id).hash())
		var column: int = index % columns
		var row: int = floori(float(index) / float(columns))
		var stable_offset := Vector2(
			float(identity_hash % 13) - 6.0,
			float(floori(float(identity_hash) / 19.0) % 11) - 5.0,
		)
		var lane_offset: float = 18.0 if row % 2 == 1 else 0.0
		item_node.custom_minimum_size = item_size
		item_node.size = item_size
		item_node.position = (
			origin
			+ Vector2(
				float(column) * cell_size.x + lane_offset,
				float(row) * cell_size.y,
			)
			+ stable_offset
		)


func _configure_bag_item_focus() -> void:
	if _current_section != Section.BAG:
		return
	var controls: Array[BagItemSpriteType] = []
	for owned: OwnedItemType in _sorted_bag_items:
		var item_node := _bag_item_nodes.get(
			owned.item_id
		) as BagItemSpriteType
		if item_node != null:
			controls.append(item_node)
	if controls.is_empty():
		_bag_sub_tab.focus_neighbor_bottom = NodePath()
		return
	_bag_sub_tab.focus_neighbor_bottom = _bag_sub_tab.get_path_to(
		controls.front()
	)
	for index: int in controls.size():
		var control: BagItemSpriteType = controls[index]
		var column: int = index % 3
		var row: int = floori(float(index) / 3.0)
		var left_index: int = row * 3 + maxi(column - 1, 0)
		var right_index: int = mini(index + 1, controls.size() - 1)
		control.focus_neighbor_left = control.get_path_to(
			controls[left_index]
		)
		control.focus_neighbor_right = control.get_path_to(
			controls[right_index]
		)
		control.focus_neighbor_top = (
			control.get_path_to(_bag_sub_tab)
			if index < 3
			else control.get_path_to(controls[index - 3])
		)
		control.focus_neighbor_bottom = control.get_path_to(
			controls[mini(index + 3, controls.size() - 1)]
		)


func _sort_bag_items(a: OwnedItemType, b: OwnedItemType) -> bool:
	var item_a: ItemDataType = _item_catalog.get_item_by_id(a.item_id)
	var item_b: ItemDataType = _item_catalog.get_item_by_id(b.item_id)
	if item_a == null:
		return false
	if item_b == null:
		return true
	if item_a.category != item_b.category:
		return item_a.category < item_b.category
	return item_a.display_name.naturalnocasecmp_to(item_b.display_name) < 0


func _select_bag_item(item_id: StringName) -> void:
	if _bag_drag_active or get_viewport().gui_is_dragging():
		return
	_selected_bag_item_id = item_id
	for candidate_id: StringName in _bag_item_nodes:
		var item_node := _bag_item_nodes[candidate_id] as BagItemSpriteType
		item_node.set_selected(candidate_id == item_id)
	_update_bag_detail()


func _update_bag_detail() -> void:
	var item: ItemDataType = (
		_item_catalog.get_item_by_id(_selected_bag_item_id)
		if _item_catalog != null and not _selected_bag_item_id.is_empty()
		else null
	)
	var quantity: int = (
		_bag.get_quantity(_selected_bag_item_id)
		if _bag != null and item != null
		else 0
	)
	var hotbar_assignment: String = "not assigned"
	if item != null and _hotbar != null:
		for slot_index: int in range(PlayerHotbarType.SLOT_COUNT):
			if _hotbar.get_item_id(slot_index) == item.item_id:
				hotbar_assignment = "hotbar slot %d" % (slot_index + 1)
				break
	var item_state: String = ""
	if item != null:
		if item.equippable:
			item_state = "equippable"
		elif item.usable:
			item_state = "usable"
	_bag_detail_texture.texture = null
	_bag_detail_name.text = item.display_name if item != null else ""
	_bag_detail_data.text = (
		"%s\nquantity: %d\n%s\n%s\n%s"
		% [
			item.get_category_name(),
			quantity,
			item_state,
			hotbar_assignment,
			item.description,
		]
		if item != null
		else "select a bag item for details."
	)
	_bag_sprite_detail_texture.texture = null
	if item != null:
		_bag_detail_texture.texture = item.icon
		_bag_sprite_detail_texture.texture = item.icon
	_bag_sprite_detail_name.text = (
		item.display_name if item != null else ""
	)
	_bag_sprite_detail_data.text = (
		"%s • quantity: %d\n%s • %s\n%s"
		% [
			item.get_category_name(),
			quantity,
			item_state,
			hotbar_assignment,
			item.description,
		]
		if item != null
		else "select an item for details."
	)
	_bag_detail_constellation.visible = (
		_current_section == Section.BAG
	)


func _close_bag_detail() -> void:
	_bag_detail_constellation.visible = false


func _on_bag_field_gui_input(event: InputEvent) -> void:
	if (
		event is InputEventMouseButton
		and event.button_index == MOUSE_BUTTON_LEFT
		and event.pressed
		and not get_viewport().gui_is_dragging()
	):
		_selected_bag_item_id = StringName()
		for item_node: BagItemSpriteType in _bag_item_nodes.values():
			item_node.set_selected(false)
		_update_bag_detail()


func _on_bag_drag_started() -> void:
	_bag_drag_active = true
	_close_bag_detail()
	_set_content_interactive(false)


func _on_bag_drag_finished() -> void:
	_bag_drag_active = false
	if visible and _current_section == Section.BAG:
		_set_content_interactive(
			not _transitioning and not _page_transitioning
		)


func _on_inventory_changed() -> void:
	_refresh_economy_summary()
	_revalidate_confirmation()
	_refresh_inventory()


func _on_wallet_balance_changed(
	_new_balance: int,
	_delta: int,
) -> void:
	_refresh_economy_summary()


func _refresh_economy_summary() -> void:
	if not is_node_ready():
		return
	var balance: int = _wallet.get_balance() if _wallet != null else 0
	var held_total: int = (
		_inventory.get_total_sale_value()
		if _inventory != null
		else 0
	)
	_wallet_balance.text = "wallet: $%d" % balance
	_wallet_status.set_content("wallet", "$%d" % balance)
	_notepad_wallet_value.text = "$%d" % balance
	_capacity_status.set_content("cooler", "%d / %d" % [
		_inventory.get_all_catches().size() if _inventory != null else 0,
		_cooler_capacity.get_capacity() if _cooler_capacity != null else 0,
	])
	_notepad_capacity_value.text = "%d / %d" % [
		_inventory.get_all_catches().size() if _inventory != null else 0,
		_cooler_capacity.get_capacity() if _cooler_capacity != null else 0,
	]
	_held_value_status.set_content("held value", "$%d" % held_total)
	_held_value.text = "held fish base value: $%d" % held_total
	_cooler_count.text = "%d / %d" % [
		_inventory.get_all_catches().size() if _inventory != null else 0,
		_cooler_capacity.get_capacity() if _cooler_capacity != null else 0,
	]


func _on_cooler_capacity_changed(_level: int, _capacity: int) -> void:
	_refresh_economy_summary()


func _refresh_inventory() -> void:
	if not is_node_ready():
		return
	var catches: Array[FishCatchType] = []
	if _inventory != null:
		for fish_catch: FishCatchType in _inventory.get_all_catches():
			if fish_catch != null and fish_catch.is_valid():
				catches.append(fish_catch)
	catches.sort_custom(_compare_catches)
	_sorted_catches = catches
	_inventory_empty.visible = catches.is_empty()
	var visible_ids: Array[StringName] = []
	for fish_catch: FishCatchType in catches:
		visible_ids.append(fish_catch.catch_id)
	_fish_selection.set_visible_order(visible_ids)

	_sync_cooler_fish_nodes(catches)
	var selected: FishCatchType = (
		_inventory.get_catch(_fish_selection.get_focused_id())
		if _inventory != null
		else null
	)
	_update_sale_summary()
	_update_inventory_detail(selected)
	_update_sort_direction_text()


func _sync_cooler_fish_nodes(catches: Array[FishCatchType]) -> void:
	var retained: Dictionary[StringName, bool] = {}
	for fish_catch: FishCatchType in catches:
		retained[fish_catch.catch_id] = true
		var fish_node := _fish_nodes.get(
			fish_catch.catch_id
		) as CoolerFishSpriteType
		var is_new: bool = fish_node == null
		if is_new:
			fish_node = CoolerFishSpriteScene.instantiate()
			_fish_field.add_child(fish_node)
			_fish_nodes[fish_catch.catch_id] = fish_node
			fish_node.set_meta("new_cooler_fish", true)
			fish_node.pressed.connect(
				_on_catch_card_pressed.bind(fish_catch.catch_id)
			)
		var identity_hash: int = absi(String(fish_catch.catch_id).hash())
		fish_node.configure(
			fish_catch.catch_id,
			FishQualityType.qualified_name(
				fish_catch.fish.display_name,
				fish_catch.quality,
			),
			fish_catch.fish.display_texture,
			float(identity_hash % 628) / 100.0,
			0.96 + float(identity_hash % 9) * 0.01,
			UIPalette.get_quality_color(fish_catch.quality),
		)
		fish_node.set_item_state(
			_fish_selection.is_selected(fish_catch.catch_id),
			fish_catch.catch_id == _fish_selection.get_focused_id(),
			fish_catch.is_favorited,
		)
	for catch_id: StringName in _fish_nodes.keys():
		if retained.has(catch_id):
			continue
		var removed := _fish_nodes[catch_id] as CoolerFishSpriteType
		_fish_nodes.erase(catch_id)
		_release_focus_from(removed, _inventory_tab)
		removed.queue_free()
	_layout_cooler_fish(true)
	_configure_cooler_fish_focus()


func _layout_cooler_fish(animate: bool = true) -> void:
	if not is_node_ready():
		return
	var columns: int = 3 if _compact_layout else 9
	var cell_size := (
		Vector2(68.0, 58.0) if _compact_layout else Vector2(84.0, 80.0)
	)
	var fish_size := (
		Vector2(52.0, 52.0) if _compact_layout else Vector2(72.0, 72.0)
	)
	var visible_field_size := (
		Vector2(218.0, 124.0)
		if _compact_layout
		else Vector2(800.0, 280.0)
	)
	var side_margin: float = 2.0 if _compact_layout else 8.0
	var top_margin: float = 4.0 if _compact_layout else 6.0
	var required_rows: int = ceili(
		float(_sorted_catches.size()) / float(columns)
	) if not _sorted_catches.is_empty() else 0
	var required_height: float = (
		top_margin * 2.0 + float(required_rows) * cell_size.y
	)
	var content_size := Vector2(
		visible_field_size.x,
		maxf(visible_field_size.y, required_height),
	)
	_cooler_host.custom_minimum_size = content_size
	_fish_field.custom_minimum_size = content_size
	_cooler_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
	for index: int in _sorted_catches.size():
		var fish_catch: FishCatchType = _sorted_catches[index]
		var fish_node := _fish_nodes.get(
			fish_catch.catch_id
		) as CoolerFishSpriteType
		if fish_node == null:
			continue
		var identity_hash: int = absi(String(fish_catch.catch_id).hash())
		var column: int = index % columns
		var row: int = floori(float(index) / float(columns))
		var offset_span: int = 5 if _compact_layout else 7
		var offset_center: float = 2.0 if _compact_layout else 3.0
		var stable_offset := Vector2(
			float(identity_hash % offset_span) - offset_center,
			float(floori(float(identity_hash) / 17.0) % offset_span) - offset_center,
		)
		var lane_offset: float = 5.0 if row % 2 == 1 else 0.0
		var target := Vector2(
			side_margin + float(column) * cell_size.x + lane_offset,
			top_margin + float(row) * cell_size.y,
		) + stable_offset
		fish_node.custom_minimum_size = fish_size
		fish_node.size = fish_size
		var immediate: bool = (
			not animate or fish_node.has_meta("new_cooler_fish")
		)
		fish_node.set_target_position(target, immediate)
		if fish_node.has_meta("new_cooler_fish"):
			fish_node.remove_meta("new_cooler_fish")


func _configure_cooler_fish_focus() -> void:
	if _current_section != Section.COOLER:
		return
	var controls: Array[CoolerFishSpriteType] = []
	for fish_catch: FishCatchType in _sorted_catches:
		var fish_node := _fish_nodes.get(
			fish_catch.catch_id
		) as CoolerFishSpriteType
		if fish_node != null:
			controls.append(fish_node)
	var columns: int = 3 if _compact_layout else 9
	if controls.is_empty():
		if not _shop_cooler_context_active:
			_cooler_sub_tab.focus_neighbor_bottom = NodePath()
		return
	var top_control: Control
	if _shop_cooler_context_active:
		top_control = _cooler_sort_option
	else:
		top_control = _cooler_sub_tab
	if not _shop_cooler_context_active:
		_cooler_sub_tab.focus_neighbor_bottom = _cooler_sub_tab.get_path_to(
			controls.front()
		)
	for index: int in controls.size():
		var control: CoolerFishSpriteType = controls[index]
		control.focus_neighbor_left = control.get_path_to(
			controls[maxi(index - 1, 0)]
		)
		control.focus_neighbor_right = control.get_path_to(
			controls[mini(index + 1, controls.size() - 1)]
		)
		control.focus_neighbor_top = (
			control.get_path_to(top_control)
			if index < columns
			else control.get_path_to(controls[index - columns])
		)
		control.focus_neighbor_bottom = (
			control.get_path_to(_favorite_bubble)
			if (
				not _favorite_bubble.disabled
				and index + columns >= controls.size()
			)
			else control.get_path_to(_sell_all_bubble)
			if (
				not _sell_all_bubble.disabled
				and index + columns >= controls.size()
			)
			else control.get_path_to(
				controls[mini(index + columns, controls.size() - 1)]
			)
		)
	var focused_node := _fish_nodes.get(
		_fish_selection.get_focused_id()
	) as CoolerFishSpriteType
	if focused_node == null:
		focused_node = controls.front()
	_favorite_bubble.focus_neighbor_left = (
		_favorite_bubble.get_path_to(focused_node)
	)
	_favorite_bubble.focus_neighbor_right = (
		_favorite_bubble.get_path_to(_sell_bubble)
	)
	_favorite_bubble.focus_neighbor_top = (
		_favorite_bubble.get_path_to(focused_node)
	)
	_sell_bubble.focus_neighbor_left = (
		_sell_bubble.get_path_to(_favorite_bubble)
	)
	_sell_bubble.focus_neighbor_right = (
		_sell_bubble.get_path_to(_close_button)
	)
	_sell_bubble.focus_neighbor_top = _sell_bubble.get_path_to(focused_node)
	_sell_bubble.focus_neighbor_bottom = (
		_sell_bubble.get_path_to(_sell_all_bubble)
	)
	_sell_all_bubble.focus_neighbor_left = (
		_sell_all_bubble.get_path_to(_favorite_bubble)
	)
	_sell_all_bubble.focus_neighbor_right = (
		_sell_all_bubble.get_path_to(_close_button)
	)
	_sell_all_bubble.focus_neighbor_top = (
		_sell_all_bubble.get_path_to(_sell_bubble)
	)


func _compare_catches(left: FishCatchType, right: FishCatchType) -> bool:
	var comparison: int = 0
	match _sort_mode:
		SortMode.CATCH_ORDER:
			comparison = left.catch_sequence - right.catch_sequence
		SortMode.NAME:
			comparison = left.fish.display_name.naturalnocasecmp_to(
				right.fish.display_name
			)
		SortMode.RARITY:
			comparison = left.fish.rarity - right.fish.rarity
	if comparison == 0:
		comparison = left.catch_sequence - right.catch_sequence
	return comparison > 0 if _sort_descending else comparison < 0


func _on_catch_card_pressed(catch_id: StringName) -> void:
	if _inventory == null:
		return
	var selected: FishCatchType = _inventory.get_catch(catch_id)
	if selected == null:
		return
	_fish_selection.apply_click(
		catch_id,
		Input.is_key_pressed(KEY_CTRL),
		Input.is_key_pressed(KEY_SHIFT)
	)
	_transaction_feedback.text = ""
	_refresh_inventory()


func _on_fish_field_gui_input(event: InputEvent) -> void:
	if (
		event is InputEventMouseButton
		and event.button_index == MOUSE_BUTTON_LEFT
		and event.pressed
		and not get_viewport().gui_is_dragging()
	):
		_fish_selection.clear()
		_transaction_feedback.text = ""
		_refresh_inventory()


func _close_detail_constellation() -> void:
	_detail_constellation.visible = _current_section == Section.COOLER
	_cooler_detail_texture.texture = null
	_cooler_detail_texture.visible = false
	_cooler_detail_name.text = "select a fish"
	_clear_cooler_detail_stats()
	_set_cooler_selection_summary_empty()
	_favorite_bubble.disabled = true
	_favorite_bubble.text = "favorite"
	_favorite_bubble.persistent_mark = false
	_sell_bubble.disabled = true
	_sell_bubble.text = "sell fish"
	_sell_bubble.persistent_mark = false
	_favorite_bubble.focus_mode = Control.FOCUS_NONE
	_sell_bubble.focus_mode = Control.FOCUS_NONE
	_favorite_bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
	_sell_bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
	_favorite_bubble.refresh_ink_state()
	_sell_bubble.refresh_ink_state()
	_refresh_cooler_notepad_action_interactivity()
	_configure_cooler_fish_focus()


func _clear_cooler_detail_stats() -> void:
	for label: Label in [
		_cooler_weight_value,
		_cooler_weight_unit,
		_cooler_offer_label,
		_cooler_offer_value,
	]:
		label.text = ""


func _set_cooler_selection_summary_empty() -> void:
	_cooler_selection_empty.visible = true
	_cooler_selected_count_row.visible = false
	_cooler_combined_offer_row.visible = false
	for label: Label in [
		_cooler_selected_count_value,
		_cooler_selected_count_label,
		_cooler_combined_offer_label,
		_cooler_combined_offer_value,
	]:
		label.visible = false
		label.text = ""


func _set_cooler_selection_summary(
	selected_count: int,
	offer_value: int,
) -> void:
	_cooler_selection_empty.visible = false
	_cooler_selected_count_row.visible = true
	_cooler_combined_offer_row.visible = true
	_cooler_selected_count_value.visible = true
	_cooler_selected_count_label.visible = true
	_cooler_combined_offer_label.visible = true
	_cooler_combined_offer_value.visible = offer_value >= 0
	_cooler_selected_count_value.text = str(selected_count)
	_cooler_selected_count_label.text = "fish selected"
	if offer_value >= 0:
		_cooler_combined_offer_label.text = "combined offer"
		_cooler_combined_offer_value.text = "$%d" % offer_value
	else:
		_cooler_combined_offer_label.text = "offer unavailable"
		_cooler_combined_offer_value.text = ""


func _update_inventory_detail(fish_catch: FishCatchType) -> void:
	if fish_catch == null:
		_cooler_detail_texture.texture = null
		_cooler_detail_texture.visible = false
		_cooler_detail_name.text = "select a fish"
		_clear_cooler_detail_stats()
		_favorite_button.disabled = true
		_favorite_button.text = "favorite"
		_favorite_bubble.disabled = true
		_favorite_bubble.text = "favorite"
		_favorite_bubble.persistent_mark = false
		_detail_constellation.visible = _current_section == Section.COOLER
		_favorite_bubble.focus_mode = Control.FOCUS_NONE
		_sell_bubble.focus_mode = Control.FOCUS_NONE
		_favorite_bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
		_sell_bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
		_favorite_bubble.refresh_ink_state()
		_sell_bubble.refresh_ink_state()
		_refresh_cooler_notepad_action_interactivity()
		_configure_cooler_fish_focus()
		return
	_cooler_detail_texture.texture = fish_catch.fish.display_texture
	_cooler_detail_texture.visible = fish_catch.fish.display_texture != null
	_cooler_detail_name.text = fish_catch.fish.display_name
	var active_buyer: FishBuyerProfileType = _get_active_sale_buyer()
	var individual_preview: FishSaleResultType = (
		_sale_service.preview_batch([fish_catch.catch_id], active_buyer)
		if _sale_service != null and active_buyer != null
		else null
	)
	var buyer_offer: int = (
		individual_preview.payout
		if individual_preview != null
		else -1
	)
	_cooler_weight_value.text = "%.2f" % fish_catch.weight_lb
	if (
		_reservations != null
		and _reservations.is_fish_reserved(fish_catch.catch_id)
	):
		_cooler_detail_name.text += " • reserved in mail"
	_cooler_weight_unit.text = "lb • %s • %s" % [
		FishQualityType.display_name(fish_catch.quality),
		fish_catch.fish.get_rarity_name(),
	]
	if buyer_offer >= 0 and active_buyer != null:
		_cooler_offer_label.text = _get_offer_label(active_buyer)
		_cooler_offer_value.text = "$%d" % buyer_offer
	else:
		_cooler_offer_label.text = "buyer unavailable"
		_cooler_offer_value.text = ""
	_favorite_button.disabled = false
	_favorite_button.text = (
		"unfavorite" if fish_catch.is_favorited else "favorite"
	)
	_favorite_bubble.disabled = false
	_favorite_bubble.text = _favorite_button.text
	_favorite_bubble.persistent_mark = fish_catch.is_favorited
	_favorite_bubble.refresh_ink_state()
	_detail_constellation.visible = _current_section == Section.COOLER
	_cooler_sort_controls.visible = true
	_refresh_cooler_notepad_action_interactivity()
	_configure_cooler_fish_focus()


func _refresh_cooler_notepad_action_interactivity() -> void:
	var detail_interactive: bool = (
		_detail_constellation.visible
		and _current_section == Section.COOLER
		and (visible or _shop_cooler_context_active)
		and not _transitioning
		and not _page_transitioning
		and not _sale_confirmation.visible
	)
	for action: NotepadInkActionType in [
		_favorite_bubble,
		_sell_bubble,
		_sell_all_bubble,
	]:
		var action_interactive: bool = detail_interactive and not action.disabled
		if action_interactive:
			action.focus_mode = Control.FOCUS_ALL
			action.mouse_filter = Control.MOUSE_FILTER_STOP
		else:
			action.focus_mode = Control.FOCUS_NONE
			action.mouse_filter = Control.MOUSE_FILTER_IGNORE
		action.refresh_ink_state()


func _update_sale_summary() -> void:
	var active_buyer: FishBuyerProfileType = _get_active_sale_buyer()
	var buyer_id: StringName = (
		active_buyer.id if active_buyer != null else StringName()
	)
	_update_sell_all_action(active_buyer, buyer_id)
	var offer_label: String = _get_offer_label(active_buyer)
	var selected_ids: Array[StringName] = _fish_selection.get_selected_ids()
	var selected_count: int = selected_ids.size()
	_sell_button.text = (
		"sell fish"
		if selected_count == 1
		else "sell %d fish" % selected_count
	)
	_sell_bubble.text = _sell_button.text
	if selected_count == 0:
		_selection_summary.text = "no fish selected"
		_selection_status.set_content("selected", "none")
		_offer_status.set_content(offer_label, "—")
		_sell_button.text = "sell fish"
		_sell_button.disabled = true
		_sell_bubble.disabled = true
		_sell_bubble.text = "sell fish"
		_sell_bubble.persistent_mark = false
		_sell_bubble.refresh_ink_state()
		_set_cooler_selection_summary_empty()
		_sale_unavailable.text = ""
		_sale_unavailable.visible = false
		return
	if (
		_sale_in_progress
		or (
			_network_sale_service != null
			and _network_sale_service.is_local_sale_pending()
		)
	):
		_selection_summary.text = (
			"1 fish selected"
			if selected_count == 1
			else "%d fish selected" % selected_count
		)
		_selection_status.set_content("selected", str(selected_count))
		_offer_status.set_content(offer_label, "pending")
		_sell_button.disabled = true
		_sell_bubble.disabled = true
		_sale_unavailable.text = "Selling…"
		_sale_unavailable.visible = true
		return
	if (
		_network_sale_service == null
		or buyer_id.is_empty()
		or not _network_sale_service.can_request_sale(buyer_id)
	):
		_selection_summary.text = (
			"1 fish selected"
			if selected_count == 1
			else "%d fish selected" % selected_count
		)
		_selection_status.set_content("selected", str(selected_count))
		_offer_status.set_content(offer_label, "unavailable")
		_sell_button.disabled = true
		_sell_bubble.disabled = true
		_sell_bubble.persistent_mark = false
		_sell_bubble.refresh_ink_state()
		_sale_unavailable.text = (
			"Selling is not supported by this server."
		)
		_sale_unavailable.visible = true
		return
	var preview: FishSaleResultType = (
		_sale_service.preview_batch(selected_ids, active_buyer)
		if _sale_service != null and active_buyer != null
		else null
	)
	var count_text: String = (
		"1 fish selected"
		if selected_count == 1
		else "%d fish selected" % selected_count
	)
	_selection_summary.text = count_text
	_selection_status.set_content("selected", str(selected_count))
	if (
		preview != null
		and preview.payout >= 0
		and active_buyer != null
		and (
			preview.is_success()
			or preview.status == FishSaleResultType.Status.FAVORITED
		)
	):
		_selection_summary.text += "\n%s total offer: $%d" % [
			active_buyer.display_name,
			preview.payout,
		]
		_set_cooler_selection_summary(selected_count, preview.payout)
		_offer_status.set_content(offer_label, "$%d" % preview.payout)
	else:
		_set_cooler_selection_summary(selected_count, -1)
		_offer_status.set_content(offer_label, "unavailable")
	_sell_button.disabled = preview == null or not preview.is_success()
	_sell_bubble.disabled = _sell_button.disabled
	_sell_bubble.persistent_mark = false
	_sell_bubble.refresh_ink_state()
	if preview != null and preview.status == FishSaleResultType.Status.FAVORITED:
		_sale_unavailable.text = (
			"favorited fish cannot be sold. "
			+ "remove them from the selection first."
		)
	elif preview != null and not preview.is_success():
		_sale_unavailable.text = preview.get_message()
	else:
		_sale_unavailable.text = ""
	_sale_unavailable.visible = not _sale_unavailable.text.is_empty()


func _update_sell_all_action(
	active_buyer: FishBuyerProfileType,
	buyer_id: StringName,
) -> void:
	var sell_all_ids: Array[StringName] = _get_sell_all_catch_ids()
	var sale_available: bool = (
		not _sale_in_progress
		and (
			_network_sale_service == null
			or not _network_sale_service.is_local_sale_pending()
		)
		and _network_sale_service != null
		and not buyer_id.is_empty()
		and _network_sale_service.can_request_sale(buyer_id)
		and _sale_service != null
		and active_buyer != null
		and not sell_all_ids.is_empty()
	)
	if sale_available:
		var preview: FishSaleResultType = _sale_service.preview_batch(
			sell_all_ids,
			active_buyer,
		)
		sale_available = preview.is_success()
	_sell_all_bubble.text = "sell all fish"
	_sell_all_bubble.disabled = not sale_available
	_sell_all_bubble.persistent_mark = false
	_sell_all_bubble.refresh_ink_state()


func _get_sell_all_catch_ids() -> Array[StringName]:
	var catch_ids: Array[StringName] = []
	if _inventory == null:
		return catch_ids
	for fish_catch: FishCatchType in _inventory.get_all_catches():
		if (
			fish_catch == null
			or not fish_catch.is_valid()
			or fish_catch.is_favorited
			or (
				_reservations != null
				and _reservations.is_fish_reserved(fish_catch.catch_id)
			)
		):
			continue
		catch_ids.append(fish_catch.catch_id)
	return catch_ids


func _on_favorite_pressed() -> void:
	var focused_id: StringName = _fish_selection.get_focused_id()
	if _inventory == null or focused_id.is_empty():
		return
	var fish_catch: FishCatchType = _inventory.get_catch_by_id(
		focused_id
	)
	if fish_catch == null:
		_refresh_inventory()
		return
	if _inventory.set_catch_favorited(
		fish_catch.catch_id,
		not fish_catch.is_favorited
	):
		_transaction_feedback.text = (
			"%s %s."
			% [
				fish_catch.fish.display_name,
				"favorited" if fish_catch.is_favorited else "unfavorited",
			]
		)


func _on_sell_pressed() -> void:
	_begin_sale_confirmation(_fish_selection.get_selected_ids())


func _on_sell_all_pressed() -> void:
	var catch_ids: Array[StringName] = _get_sell_all_catch_ids()
	if catch_ids.is_empty():
		_transaction_feedback.text = "No sellable fish in the cooler."
		return
	_begin_sale_confirmation(catch_ids)


func _begin_sale_confirmation(catch_ids: Array[StringName]) -> void:
	var active_buyer: FishBuyerProfileType = _get_active_sale_buyer()
	var buyer_id: StringName = (
		active_buyer.id if active_buyer != null else StringName()
	)
	if (
		_sale_in_progress
		or (
			_network_sale_service != null
			and _network_sale_service.is_local_sale_pending()
		)
	):
		_transaction_feedback.text = "Selling…"
		return
	if (
		_network_sale_service == null
		or buyer_id.is_empty()
		or not _network_sale_service.can_request_sale(buyer_id)
	):
		_transaction_feedback.text = (
			"Selling is not supported by this server."
		)
		return
	if (
		_inventory == null
		or _sale_service == null
		or active_buyer == null
		or catch_ids.is_empty()
	):
		return
	var preview: FishSaleResultType = _sale_service.preview_batch(
		catch_ids,
		active_buyer
	)
	if not preview.is_success():
		_transaction_feedback.text = (
			"favorited fish cannot be sold. "
			+ "remove them from the selection first."
			if preview.status == FishSaleResultType.Status.FAVORITED
			else preview.get_message()
		)
		_refresh_inventory()
		return
	_confirmation_catch_ids = catch_ids.duplicate()
	_confirmation_buyer = active_buyer
	_confirmation_buyer_id = active_buyer.id
	_confirmation_generation = _menu_generation
	var fish_label: String = "fish"
	_confirmation_message.text = (
		"sell %d %s to the %s for $%d?\ncombined base value: $%d"
		% [
			preview.fish_count,
			fish_label,
			_get_buyer_display_group(active_buyer),
			preview.payout,
			preview.base_value,
		]
	)
	_sale_confirmation.visible = true
	if _shop_cooler_context_active:
		shop_cooler_modal_changed.emit(true)
	_set_shell_interactive(false)
	for button: Button in [_confirm_sale_button, _cancel_sale_button]:
		button.focus_mode = Control.FOCUS_ALL
		button.mouse_filter = Control.MOUSE_FILTER_STOP
	_confirm_sale_button.disabled = false
	_cancel_sale_button.grab_focus()


func _on_confirm_sale_pressed() -> void:
	if (
		_sale_in_progress
		or _network_sale_service == null
		or not _network_sale_service.can_request_sale(
			_confirmation_buyer_id
		)
		or _sale_service == null
		or _confirmation_catch_ids.is_empty()
		or _confirmation_buyer == null
		or _confirmation_buyer.id != _confirmation_buyer_id
		or _confirmation_generation != _menu_generation
	):
		return
	_sale_in_progress = true
	var requested_catch_ids: Array[StringName] = (
		_confirmation_catch_ids.duplicate()
	)
	var requested_buyer_id: StringName = _confirmation_buyer_id
	var transaction_generation: int = _menu_generation
	_confirm_sale_button.disabled = true
	_close_sale_confirmation()
	var request_id: String = _network_sale_service.request_local_sale(
		requested_catch_ids,
		requested_buyer_id,
	)
	if request_id.is_empty():
		_sale_in_progress = false
		return
	if (
		_network_sale_service.is_local_sale_pending()
		and transaction_generation == _menu_generation
		and (visible or _shop_cooler_context_active)
	):
		_transaction_feedback.text = "Selling…"


func _can_use_shared_world_actions() -> bool:
	return (
		_network_sale_service != null
		and _network_sale_service.can_request_sale()
	)


func _on_network_sale_pending(_request_id: String) -> void:
	_sale_in_progress = true
	if visible or _shop_cooler_context_active:
		_transaction_feedback.text = "Selling…"
		_update_sale_summary()


func _on_network_sale_finished(
	_request_id: String,
	accepted: bool,
	message: String,
	catch_ids: Array[StringName],
	payout: int,
) -> void:
	_sale_in_progress = false
	if accepted:
		_fish_selection.remove_ids(catch_ids)
	if not visible and not _shop_cooler_context_active:
		return
	_refresh_all()
	var feedback_message: String = (
		"Sale complete. $%d received." % payout
		if accepted
		else message
	)
	_transaction_feedback.text = feedback_message
	if not accepted:
		_sale_unavailable.text = feedback_message
		_sale_unavailable.visible = true
	if _current_section == Section.COOLER:
		call_deferred("_restore_inventory_tab_focus")


func _restore_inventory_tab_focus() -> void:
	if _shop_cooler_context_active:
		_focus_shop_cooler()
		return
	if (
		visible
		and _current_section == Section.COOLER
		and _inventory_tab.focus_mode != Control.FOCUS_NONE
		and _inventory_tab.is_visible_in_tree()
	):
		_inventory_tab.grab_focus()


func _close_sale_confirmation() -> void:
	var was_visible: bool = _sale_confirmation.visible
	_confirmation_catch_ids.clear()
	_confirmation_buyer = null
	_confirmation_buyer_id = StringName()
	_confirmation_generation = -1
	_sale_confirmation.visible = false
	if was_visible and _shop_cooler_context_active:
		shop_cooler_modal_changed.emit(false)
	_confirmation_message.text = ""
	_confirm_sale_button.disabled = false
	for button: Button in [_confirm_sale_button, _cancel_sale_button]:
		button.focus_mode = Control.FOCUS_NONE
		button.mouse_filter = Control.MOUSE_FILTER_IGNORE
	if (
		was_visible
		and (visible or _shop_cooler_context_active)
		and not _transitioning
		and not _page_transitioning
	):
		_set_shell_interactive(true)
		_focus_current_section()


func _revalidate_confirmation() -> void:
	if not _sale_confirmation.visible:
		return
	if (
		_confirmation_generation != _menu_generation
		or _confirmation_catch_ids.is_empty()
		or _sale_service == null
		or _confirmation_buyer == null
		or _confirmation_buyer.id != _confirmation_buyer_id
	):
		_close_sale_confirmation()
		_transaction_feedback.text = "sale selection is no longer available."
		return
	var preview: FishSaleResultType = _sale_service.preview_batch(
		_confirmation_catch_ids,
		_confirmation_buyer
	)
	if (
		not preview.is_success()
		or (
		_confirmation_buyer == null
		or _confirmation_buyer.id != _confirmation_buyer_id
		or not _confirmation_buyer.is_valid()
		)
	):
		_close_sale_confirmation()
		_transaction_feedback.text = (
			"favorited fish cannot be sold. "
			+ "remove them from the selection first."
			if preview.status == FishSaleResultType.Status.FAVORITED
			else preview.get_message()
		)


func _get_buyer_display_group(
	buyer: FishBuyerProfileType,
) -> String:
	if buyer == null:
		return "buyer"
	if not buyer.animal_name_plural.is_empty():
		return buyer.animal_name_plural
	if not buyer.display_name.is_empty():
		return buyer.display_name
	return "buyer"


func _get_active_sale_buyer() -> FishBuyerProfileType:
	if _sale_buyer_override != null and _sale_buyer_override.is_valid():
		return _sale_buyer_override
	return _default_buyer


func _get_offer_label(buyer: FishBuyerProfileType) -> String:
	if buyer == null:
		return "buyer offer"
	if buyer.id == MAIN_SHOP_BUYER_ID:
		return "shop offer"
	if buyer.id == NetworkSaleService.PELICAN_BUYER_ID:
		return "pelican offer"
	return "buyer offer"


func _refresh_logbook() -> void:
	if not is_node_ready():
		return
	var valid_species: Array[FishDataType] = []
	if _catalog != null:
		for fish: FishDataType in _catalog.candidates:
			if fish != null and not fish.id.is_empty():
				valid_species.append(fish)
	_logbook_species = valid_species
	_logbook_empty.visible = valid_species.is_empty()
	_logbook_empty.text = (
		"no fish catalog configured."
		if valid_species.is_empty()
		else "no species discovered yet."
	)
	_logbook_empty_state.visible = valid_species.is_empty()
	_logbook_empty_state.text = (
		"no fish catalog configured"
		if valid_species.is_empty()
		else ""
	)
	_refresh_logbook_page(false)


func _refresh_logbook_page(keep_transition_state: bool = true) -> void:
	if not is_node_ready():
		return
	var entries_per_spread: int = 2 if _compact_layout else 4
	_logbook_page_count = maxi(
		1,
		ceili(float(_logbook_species.size()) / float(entries_per_spread)),
	)
	_logbook_current_page = clampi(
		_logbook_current_page,
		0,
		_logbook_page_count - 1,
	)
	_clear_logbook_entries()
	var start_index: int = _logbook_current_page * entries_per_spread
	var end_index: int = mini(
		start_index + entries_per_spread,
		_logbook_species.size(),
	)
	var visible_species: Array[FishDataType] = []
	for index: int in range(start_index, end_index):
		visible_species.append(_logbook_species[index])
	if _compact_layout:
		for fish: FishDataType in visible_species:
			_left_entry_field.add_child(_create_logbook_entry(fish))
	else:
		var left_count: int = mini(2, visible_species.size())
		for index: int in visible_species.size():
			var destination: BoxContainer = (
				_left_entry_field
				if index < left_count
				else _right_entry_field
			)
			destination.add_child(_create_logbook_entry(
				visible_species[index]
			))
	_logbook_page_status.set_content(
		"pages",
		"%d / %d" % [_logbook_current_page + 1, _logbook_page_count],
	)
	_logbook_page_status.visible = _logbook_page_count > 1
	_update_logbook_page_control_state(
		keep_transition_state
		and visible
		and _current_section == Section.LOGBOOK
		and not _transitioning
		and not _page_transitioning
		and not _logbook_page_transitioning
	)
	_configure_logbook_focus()


func _create_logbook_entry(fish: FishDataType) -> LogbookEntryType:
	var discovered: bool = (
		_collection_log != null
		and _collection_log.has_discovered(fish.id)
	)
	var entry := LogbookEntryScene.instantiate() as LogbookEntryType
	entry.set_meta("fish_id", fish.id)
	entry.configure(
		fish.display_texture,
		fish.display_name,
		fish.get_rarity_name(),
		_inventory.get_count(fish.id) if _inventory != null else 0,
		discovered,
	)
	entry.apply_compact_layout(_compact_layout)
	return entry


func _clear_logbook_entries() -> void:
	for field: BoxContainer in [_left_entry_field, _right_entry_field]:
		for child: Node in field.get_children():
			_release_focus_from(child, _logbook_tab)
			field.remove_child(child)
			child.queue_free()


func _release_focus_from(node: Node, fallback: Control) -> void:
	var focus_owner: Control = get_viewport().gui_get_focus_owner()
	if (
		focus_owner == null
		or (
			focus_owner != node
			and not node.is_ancestor_of(focus_owner)
		)
	):
		return
	var current_viewport: Viewport = get_viewport()
	if current_viewport != null:
		current_viewport.gui_release_focus()
	if (
		visible
		and not _transitioning
		and not _page_transitioning
		and fallback != null
		and fallback.focus_mode != Control.FOCUS_NONE
	):
		fallback.call_deferred("grab_focus")


func _request_logbook_page(direction: int) -> void:
	if (
		_current_section != Section.LOGBOOK
		or _logbook_page_transitioning
		or _transitioning
		or _page_transitioning
	):
		return
	var target_page: int = clampi(
		_logbook_current_page + direction,
		0,
		_logbook_page_count - 1,
	)
	if target_page == _logbook_current_page:
		return
	_logbook_page_generation += 1
	_logbook_page_transitioning = true
	_update_logbook_page_control_state(false)
	var generation: int = _logbook_page_generation
	var travel: float = -12.0 if direction > 0 else 12.0
	_logbook_page_tween = create_tween()
	_logbook_page_tween.tween_property(
		_book_spread,
		"modulate:a",
		0.0,
		LOGBOOK_PAGE_DURATION * 0.5,
	).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
	_logbook_page_tween.parallel().tween_property(
		_book_spread,
		"position:x",
		travel,
		LOGBOOK_PAGE_DURATION * 0.5,
	).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
	_logbook_page_tween.tween_callback(
		_swap_logbook_page.bind(target_page, travel, generation)
	)
	_logbook_page_tween.tween_property(
		_book_spread,
		"modulate:a",
		1.0,
		LOGBOOK_PAGE_DURATION * 0.5,
	).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
	_logbook_page_tween.parallel().tween_property(
		_book_spread,
		"position:x",
		0.0,
		LOGBOOK_PAGE_DURATION * 0.5,
	).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
	_logbook_page_tween.finished.connect(
		_finish_logbook_page_transition.bind(generation),
		CONNECT_ONE_SHOT,
	)


func _swap_logbook_page(
	target_page: int,
	travel: float,
	generation: int,
) -> void:
	if (
		generation != _logbook_page_generation
		or not visible
		or _current_section != Section.LOGBOOK
	):
		return
	_logbook_current_page = target_page
	_refresh_logbook_page(false)
	_book_spread.position.x = -travel
	_book_spread.modulate.a = 0.0


func _finish_logbook_page_transition(generation: int) -> void:
	if generation != _logbook_page_generation:
		return
	_logbook_page_tween = null
	_logbook_page_transitioning = false
	_book_spread.position.x = 0.0
	_book_spread.modulate.a = 1.0
	_update_logbook_page_control_state(
		visible and _current_section == Section.LOGBOOK
	)
	_configure_logbook_focus()
	if _logbook_next.visible and not _logbook_next.disabled:
		_logbook_next.grab_focus()
	elif _logbook_previous.visible and not _logbook_previous.disabled:
		_logbook_previous.grab_focus()


func _cancel_logbook_page_transition(reset_visuals: bool) -> void:
	_logbook_page_generation += 1
	if _logbook_page_tween != null:
		_logbook_page_tween.kill()
		_logbook_page_tween = null
	_logbook_page_transitioning = false
	if reset_visuals and is_instance_valid(_book_spread):
		_book_spread.position.x = 0.0
		_book_spread.modulate.a = 1.0


func _update_logbook_page_control_state(interactive: bool) -> void:
	var has_multiple_pages: bool = _logbook_page_count > 1
	_logbook_previous.visible = has_multiple_pages
	_logbook_next.visible = has_multiple_pages
	_logbook_previous.disabled = (
		not interactive or _logbook_current_page <= 0
	)
	_logbook_next.disabled = (
		not interactive
		or _logbook_current_page >= _logbook_page_count - 1
	)
	for control: BubbleButtonType in [_logbook_previous, _logbook_next]:
		control.focus_mode = (
			Control.FOCUS_ALL
			if control.visible and not control.disabled
			else Control.FOCUS_NONE
		)
		control.mouse_filter = (
			Control.MOUSE_FILTER_STOP
			if control.visible and not control.disabled
			else Control.MOUSE_FILTER_IGNORE
		)


func _configure_logbook_focus() -> void:
	if _current_section != Section.LOGBOOK:
		return
	_logbook_tab.focus_neighbor_bottom = _logbook_tab.focus_neighbor_right
	if _logbook_page_count <= 1:
		return
	var first_control: BubbleButtonType = (
		_logbook_previous
		if not _logbook_previous.disabled
		else _logbook_next
	)
	_logbook_tab.focus_neighbor_bottom = _logbook_tab.get_path_to(
		first_control
	)
	for control: BubbleButtonType in [_logbook_previous, _logbook_next]:
		control.focus_neighbor_top = control.get_path_to(_logbook_tab)
		control.focus_neighbor_bottom = control.focus_neighbor_top
		control.focus_neighbor_left = control.get_path_to(
			_logbook_previous
		)
		control.focus_neighbor_right = control.get_path_to(_logbook_next)


func _advance_logbook_page_controls(delta: float) -> void:
	for control: BubbleButtonType in [_logbook_previous, _logbook_next]:
		if not control.visible:
			continue
		control.advance_emphasis(delta)
		control.apply_presentation(
			control.calculate_target(_motion_elapsed, 1.0, 1.0),
			control.calculate_visual_scale(_motion_elapsed, 1.0),
			minf(1.0, delta * 10.0),
		)


func _create_texture_frame(
	texture: Texture2D,
	minimum_size: Vector2,
) -> TextureRect:
	var texture_frame := TextureRect.new()
	texture_frame.custom_minimum_size = minimum_size
	texture_frame.texture = texture
	texture_frame.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
	texture_frame.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	texture_frame.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
	texture_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
	if texture == null:
		texture_frame.tooltip_text = "display texture unavailable"
	return texture_frame


func _clear_container(container: Container) -> void:
	for child: Node in container.get_children():
		container.remove_child(child)
		child.queue_free()
