class_name LogbookPage
extends Control

const CollectionLogType = preload("res://collection/collection_log.gd")
const FishDataType = preload("res://fish/fish_data.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishPoolType = preload("res://fish/fish_pool.gd")
const SILHOUETTE_SHADER: Shader = preload(
	"res://ui/logbook_silhouette.gdshader"
)
const OrganizerTabType = preload("res://ui/components/organizer_tab.gd")
const InventoryNotepadType = preload(
	"res://ui/components/inventory_notepad.gd"
)
const LogbookPortraitType = preload(
	"res://ui/components/logbook_portrait.gd"
)

const DETAIL_FADE_DURATION: float = 0.12
const CATEGORY_FADE_DURATION: float = UIMotion.UTILITY_EXIT_DURATION
const HANDWRITTEN_NUMERIC_SCALE: float = 0.8
const PORTRAIT_VIEW_MAX_SIZE := Vector2(860.0, 480.0)
const INK := Color("251b10")
const MUTED_INK := Color("6d5b45")
const LOGBOOK_ARTWORK: Texture2D = preload(
	"res://ui/assets/logbook/ui_logbook.png"
)
const SCROLL_UP_TEXTURE: Texture2D = preload(
	"res://ui/icons/pictograms/arrow_dark_up_full.png"
)
const SCROLL_DOWN_TEXTURE: Texture2D = preload(
	"res://ui/icons/pictograms/arrow_dark_down_full.png"
)
const LOGBOOK_ARTWORK_SOURCE_SIZE := Vector2(512.0, 247.0)
const LEFT_PAGE_CONTENT_RECT := Rect2(56.0, 26.0, 190.0, 198.0)
const RIGHT_PAGE_CONTENT_RECT := Rect2(267.0, 26.0, 210.0, 198.0)
const LOGBOOK_TAB_LEFT_INSET: float = 122.0
const PAGE_CONTENT_SCALE: float = 0.97
const CATALOG_PORTRAIT_SIZE := Vector2(78.0, 36.0)
const CATALOG_ENTRY_SIZE := Vector2(92.0, 92.0)
const CATALOG_ROW_STEP: float = 98.0
const CATALOG_SNAP_DELAY: float = 0.12
const DETAIL_PORTRAIT_SIZE := Vector2(160.0, 88.0)
const DETAIL_BOTTOM_INSET: float = 35.0

var _collection_log: CollectionLogType
var _inventory: FishInventoryType
var _catalog: FishPoolType
var _category: WaterType.Type = WaterType.Type.FRESH_WATER
var _selected_id: StringName
var _selected_entry_key: StringName
var _active: bool = false
var _interactive: bool = false
var _entry_buttons: Dictionary[StringName, Button] = {}
var _detail_generation: int = 0
var _detail_tween: Tween
var _category_generation: int = 0
var _category_tween: Tween
var _silhouette_material: ShaderMaterial
var _snapping_catalog_scroll: bool = false
var _catalog_scroll_snap_timer: Timer

var _category_tabs: Array[Button] = []
var _catalog_scroll: ScrollContainer
var _catalog_grid: GridContainer
var _catalog_scroll_up_indicator: TextureRect
var _catalog_scroll_down_indicator: TextureRect
var _empty_state: Label
var _detail_body: VBoxContainer
var _detail_portrait_button: Button
var _portrait_overlay: Control
var _portrait_overlay_backdrop: Button
var _portrait_overlay_artwork: LogbookPortraitType


func _ready() -> void:
	_silhouette_material = ShaderMaterial.new()
	_silhouette_material.shader = SILHOUETTE_SHADER
	_build_interface()
	set_interactive(false)


func setup(
	collection_log: CollectionLogType,
	inventory: FishInventoryType,
	catalog: FishPoolType,
) -> void:
	_collection_log = collection_log
	_inventory = inventory
	_catalog = catalog
	if not _collection_log.fish_discovered.is_connected(
		_on_fish_discovered
	):
		_collection_log.fish_discovered.connect(_on_fish_discovered)
	if not _collection_log.collection_changed.is_connected(
		_on_collection_changed
	):
		_collection_log.collection_changed.connect(_on_collection_changed)
	if not _inventory.catches_changed.is_connected(_on_inventory_changed):
		_inventory.catches_changed.connect(_on_inventory_changed)
	_refresh_catalog()


func activate() -> void:
	_active = true
	set_interactive(true)
	_refresh_catalog()
	_animate_tab_entry()


func deactivate() -> void:
	_active = false
	_hide_portrait_overlay(false)
	set_interactive(false)
	_settle_tabs_for_close()
	_cancel_detail_tween()
	_cancel_category_tween()


func set_interactive(value: bool) -> void:
	_interactive = value and _active
	mouse_filter = (
		Control.MOUSE_FILTER_PASS
		if _interactive
		else Control.MOUSE_FILTER_IGNORE
	)
	for tab: Button in _category_tabs:
		tab.focus_mode = (
			Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE
		)
		tab.mouse_filter = (
			Control.MOUSE_FILTER_STOP
			if _interactive and not tab.button_pressed
			else Control.MOUSE_FILTER_IGNORE
		)
	for entry: Button in _entry_buttons.values():
		entry.focus_mode = (
			Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE
		)
		entry.mouse_filter = (
			Control.MOUSE_FILTER_STOP
			if _interactive
			else Control.MOUSE_FILTER_IGNORE
		)


func focus_initial() -> void:
	if not _active or not _interactive:
		return
	if _portrait_overlay != null and _portrait_overlay.visible:
		_portrait_overlay_backdrop.grab_focus()
		return
	var selected := _entry_buttons.get(_selected_id) as Button
	if selected != null:
		selected.grab_focus()
	elif not _entry_buttons.is_empty():
		var first := _entry_buttons.values().front() as Button
		first.grab_focus()
	else:
		_category_tabs[int(_category)].grab_focus()


func _build_interface() -> void:
	set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	var outer := MarginContainer.new()
	outer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	outer.add_theme_constant_override("margin_left", 52)
	outer.add_theme_constant_override("margin_top", 85)
	outer.add_theme_constant_override("margin_right", 52)
	outer.add_theme_constant_override("margin_bottom", 18)
	add_child(outer)

	var stack := Control.new()
	stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
	outer.add_child(stack)

	var tabs := HBoxContainer.new()
	# Align the first flange with the authored left paper edge.
	tabs.position = Vector2(LOGBOOK_TAB_LEFT_INSET, 37.0)
	tabs.size = Vector2(384.0, 38.0)
	tabs.z_index = 10
	tabs.mouse_filter = Control.MOUSE_FILTER_PASS
	tabs.add_theme_constant_override("separation", 6)
	stack.add_child(tabs)
	for category: WaterType.Type in [
		WaterType.Type.FRESH_WATER,
		WaterType.Type.SALT_WATER,
		WaterType.Type.OTHER,
	]:
		var tab := OrganizerTabType.new()
		tab.custom_minimum_size = Vector2(124, 38)
		tab.toggle_mode = true
		tab.text = LogbookCatalog.category_label(category)
		tab.palette_index = int(category)
		tab.set_selected(category == _category, false)
		tab.pressed.connect(_select_category.bind(category))
		tabs.add_child(tab)
		_category_tabs.append(tab)
	_configure_category_focus()

	var book := Control.new()
	book.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	book.offset_top = 50.0
	book.z_index = 20
	book.mouse_filter = Control.MOUSE_FILTER_IGNORE
	stack.add_child(book)

	var artwork := TextureRect.new()
	artwork.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	artwork.texture = LOGBOOK_ARTWORK
	artwork.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
	artwork.stretch_mode = TextureRect.STRETCH_SCALE
	artwork.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
	artwork.mouse_filter = Control.MOUSE_FILTER_IGNORE
	book.add_child(artwork)

	var left_page := Control.new()
	left_page.mouse_filter = Control.MOUSE_FILTER_IGNORE
	_apply_artwork_rect(left_page, LEFT_PAGE_CONTENT_RECT)
	book.add_child(left_page)
	var left_layout := VBoxContainer.new()
	left_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	left_layout.add_theme_constant_override("separation", 8)
	left_page.add_child(left_layout)
	_apply_page_content_scale(left_layout)
	var scroll_indicator_top_lane := Control.new()
	scroll_indicator_top_lane.custom_minimum_size.y = 20.0
	scroll_indicator_top_lane.mouse_filter = Control.MOUSE_FILTER_IGNORE
	left_layout.add_child(scroll_indicator_top_lane)
	_empty_state = _label("", 18)
	_empty_state.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	_empty_state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	_empty_state.size_flags_vertical = Control.SIZE_EXPAND_FILL
	left_layout.add_child(_empty_state)
	var catalog_scroll_margin := MarginContainer.new()
	catalog_scroll_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
	catalog_scroll_margin.add_theme_constant_override("margin_left", 20)
	left_layout.add_child(catalog_scroll_margin)
	_catalog_scroll = ScrollContainer.new()
	_catalog_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
	_catalog_scroll.horizontal_scroll_mode = (
		ScrollContainer.SCROLL_MODE_DISABLED
	)
	_catalog_scroll.scroll_vertical_custom_step = CATALOG_ROW_STEP
	catalog_scroll_margin.add_child(_catalog_scroll)
	_catalog_grid = GridContainer.new()
	_catalog_grid.columns = 4
	_catalog_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	_catalog_grid.add_theme_constant_override("h_separation", 6)
	_catalog_grid.add_theme_constant_override("v_separation", 6)
	_catalog_scroll.add_child(_catalog_grid)
	var scroll_indicator_bottom_lane := Control.new()
	scroll_indicator_bottom_lane.custom_minimum_size.y = 32.0
	scroll_indicator_bottom_lane.mouse_filter = Control.MOUSE_FILTER_IGNORE
	left_layout.add_child(scroll_indicator_bottom_lane)
	_configure_catalog_scroll_bar()
	_catalog_scroll_up_indicator = _make_scroll_indicator(
		"CatalogScrollUpIndicator",
		SCROLL_UP_TEXTURE,
		true,
	)
	left_page.add_child(_catalog_scroll_up_indicator)
	_catalog_scroll_down_indicator = _make_scroll_indicator(
		"CatalogScrollDownIndicator",
		SCROLL_DOWN_TEXTURE,
		false,
	)
	left_page.add_child(_catalog_scroll_down_indicator)

	var right_page := Control.new()
	right_page.mouse_filter = Control.MOUSE_FILTER_IGNORE
	_apply_artwork_rect(right_page, RIGHT_PAGE_CONTENT_RECT)
	book.add_child(right_page)
	_detail_body = VBoxContainer.new()
	_detail_body.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	_detail_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	_detail_body.size_flags_vertical = Control.SIZE_EXPAND_FILL
	_detail_body.add_theme_constant_override("separation", 16)
	right_page.add_child(_detail_body)
	_apply_page_content_scale(_detail_body)
	_show_no_selection()
	_build_portrait_overlay()


func _apply_artwork_rect(control: Control, source_rect: Rect2) -> void:
	control.anchor_left = source_rect.position.x / LOGBOOK_ARTWORK_SOURCE_SIZE.x
	control.anchor_top = source_rect.position.y / LOGBOOK_ARTWORK_SOURCE_SIZE.y
	control.anchor_right = source_rect.end.x / LOGBOOK_ARTWORK_SOURCE_SIZE.x
	control.anchor_bottom = source_rect.end.y / LOGBOOK_ARTWORK_SOURCE_SIZE.y
	control.offset_left = 0.0
	control.offset_top = 0.0
	control.offset_right = 0.0
	control.offset_bottom = 0.0


func _apply_page_content_scale(control: Control) -> void:
	control.scale = Vector2.ONE * PAGE_CONTENT_SCALE
	control.resized.connect(_center_scaled_content.bind(control))
	_center_scaled_content.call_deferred(control)


func _center_scaled_content(control: Control) -> void:
	if is_instance_valid(control):
		control.pivot_offset = control.size * 0.5


func _configure_catalog_scroll_bar() -> void:
	_catalog_scroll_snap_timer = Timer.new()
	_catalog_scroll_snap_timer.one_shot = true
	_catalog_scroll_snap_timer.wait_time = CATALOG_SNAP_DELAY
	_catalog_scroll_snap_timer.timeout.connect(_snap_catalog_scroll_to_row)
	_catalog_scroll.add_child(_catalog_scroll_snap_timer)
	var scroll_bar: VScrollBar = _catalog_scroll.get_v_scroll_bar()
	scroll_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
	scroll_bar.focus_mode = Control.FOCUS_NONE
	scroll_bar.self_modulate = Color.TRANSPARENT
	scroll_bar.custom_minimum_size.x = 0.0
	scroll_bar.add_theme_constant_override("scroll_size", 0)
	var empty_style := StyleBoxEmpty.new()
	for style_name: StringName in [
		&"scroll",
		&"scroll_focus",
		&"grabber",
		&"grabber_highlight",
		&"grabber_pressed",
	]:
		scroll_bar.add_theme_stylebox_override(style_name, empty_style)
	scroll_bar.changed.connect(_refresh_catalog_scroll_indicators)
	scroll_bar.value_changed.connect(_on_catalog_scroll_value_changed)


func _make_scroll_indicator(
	indicator_name: String,
	indicator_texture: Texture2D,
	at_top: bool,
) -> TextureRect:
	var indicator := TextureRect.new()
	indicator.name = indicator_name
	indicator.anchor_left = 0.5
	indicator.anchor_right = 0.5
	indicator.anchor_top = 0.0 if at_top else 1.0
	indicator.anchor_bottom = indicator.anchor_top
	indicator.offset_left = -14.0
	indicator.offset_right = 14.0
	indicator.offset_top = -8.0 if at_top else -35.0
	indicator.offset_bottom = 20.0 if at_top else -7.0
	indicator.z_index = 30
	indicator.texture = indicator_texture
	indicator.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
	indicator.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	indicator.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
	indicator.mouse_filter = Control.MOUSE_FILTER_IGNORE
	indicator.visible = false
	return indicator


func _on_catalog_scroll_value_changed(_value: float) -> void:
	if _snapping_catalog_scroll:
		return
	_catalog_scroll_snap_timer.start()
	_refresh_catalog_scroll_indicators()


func _snap_catalog_scroll_to_row() -> void:
	var scroll_bar: VScrollBar = _catalog_scroll.get_v_scroll_bar()
	var value: float = scroll_bar.value
	var maximum_scroll: float = scroll_bar.max_value - scroll_bar.page
	var snapped_value: float = clampf(
		roundf(value / CATALOG_ROW_STEP) * CATALOG_ROW_STEP,
		scroll_bar.min_value,
		maximum_scroll,
	)
	if not is_equal_approx(value, snapped_value):
		_snapping_catalog_scroll = true
		_catalog_scroll.scroll_vertical = roundi(snapped_value)
		_snapping_catalog_scroll = false
	_refresh_catalog_scroll_indicators()


func _refresh_catalog_scroll_indicators() -> void:
	if (
		_catalog_scroll == null
		or _catalog_scroll_up_indicator == null
		or _catalog_scroll_down_indicator == null
	):
		return
	var scroll_bar: VScrollBar = _catalog_scroll.get_v_scroll_bar()
	var maximum_scroll: float = scroll_bar.max_value - scroll_bar.page
	var has_overflow: bool = (
		_catalog_scroll.visible
		and maximum_scroll > scroll_bar.min_value + 0.5
	)
	_catalog_scroll_up_indicator.visible = (
		has_overflow and scroll_bar.value > scroll_bar.min_value + 0.5
	)
	_catalog_scroll_down_indicator.visible = (
		has_overflow and scroll_bar.value < maximum_scroll - 0.5
	)


func _refresh_catalog() -> void:
	if not is_node_ready():
		return
	var preserved_selection: StringName = _selected_id
	var preserved_entry_key: StringName = _selected_entry_key
	_clear_entries()
	var species: Array[FishDataType] = []
	if _catalog != null:
		species = LogbookCatalog.ordered_species(_catalog.candidates)
	for fish: FishDataType in species:
		if LogbookCatalog.category_for(fish) != _category:
			continue
		var discovered: bool = _collection_log.has_discovered(fish.id)
		var entry: Button = _make_entry(fish, discovered)
		var selection_key: StringName = (
			fish.id if discovered else _unknown_selection_key(fish)
		)
		_entry_buttons[selection_key] = entry
		entry.pressed.connect(
			_select_entry.bind(
				selection_key,
				fish.id if discovered else StringName(),
			)
		)
		entry.focus_entered.connect(
			_select_entry.bind(
				selection_key,
				fish.id if discovered else StringName(),
			)
		)
		_catalog_grid.add_child(entry)
	_empty_state.text = LogbookCatalog.empty_state(_category)
	_empty_state.visible = _entry_buttons.is_empty()
	_catalog_scroll.visible = not _entry_buttons.is_empty()
	if (
		not preserved_selection.is_empty()
		and _entry_buttons.has(preserved_selection)
	):
		_selected_id = preserved_selection
		_selected_entry_key = preserved_selection
	elif _entry_buttons.has(preserved_entry_key):
		_selected_id = StringName()
		_selected_entry_key = preserved_entry_key
	else:
		_selected_id = StringName()
		_selected_entry_key = StringName()
	_refresh_selection_styles()
	_refresh_details(false)
	set_interactive(_interactive)
	_refresh_catalog_scroll_indicators.call_deferred()


func _make_entry(fish: FishDataType, discovered: bool) -> Button:
	var entry := Button.new()
	entry.set_meta(&"controller_focus_inversion_disabled", true)
	entry.custom_minimum_size = CATALOG_ENTRY_SIZE
	entry.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
	entry.size_flags_vertical = Control.SIZE_SHRINK_CENTER
	entry.toggle_mode = true
	entry.add_theme_stylebox_override("normal", _entry_style(false))
	entry.add_theme_stylebox_override("hover", _entry_style(true))
	entry.add_theme_stylebox_override("hover_pressed", _entry_style(true))
	entry.add_theme_stylebox_override("focus", _entry_style(true))
	entry.add_theme_stylebox_override("pressed", _entry_style(true))
	entry.text = ""
	if not discovered:
		entry.tooltip_text = ""
		entry.accessibility_name = "Unknown catalog entry"
		_add_entry_content(entry, fish.display_texture, "???", true)
	else:
		entry.tooltip_text = fish.display_name
		entry.accessibility_name = fish.display_name
		_add_entry_content(
			entry, fish.display_texture, fish.display_name, false
		)
	return entry


func _add_entry_content(
	entry: Button,
	portrait: Texture2D,
	entry_name: String,
	unknown: bool,
) -> void:
	var content_margin := MarginContainer.new()
	content_margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
	content_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	content_margin.add_theme_constant_override("margin_left", 5)
	content_margin.add_theme_constant_override("margin_top", 8)
	content_margin.add_theme_constant_override("margin_right", 5)
	content_margin.add_theme_constant_override("margin_bottom", 2)
	entry.add_child(content_margin)

	var content := VBoxContainer.new()
	content.mouse_filter = Control.MOUSE_FILTER_IGNORE
	content.alignment = BoxContainer.ALIGNMENT_CENTER
	content.add_theme_constant_override("separation", 4)
	content_margin.add_child(content)

	var portrait_view := LogbookPortraitType.new()
	portrait_view.configure(
		portrait,
		CATALOG_PORTRAIT_SIZE,
		_silhouette_material if unknown else null,
	)
	content.add_child(portrait_view)

	var name_label := _label(_entry_label_text(entry_name), 16)
	name_label.custom_minimum_size.y = 38.0
	name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
	name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	content.add_child(name_label)


func _select_category(category: WaterType.Type) -> void:
	if category == _category:
		return
	_category = category
	_selected_id = StringName()
	_selected_entry_key = StringName()
	for index: int in _category_tabs.size():
		(_category_tabs[index] as OrganizerTabType).set_selected(
			index == int(_category)
		)
	_begin_category_transition()


func _configure_category_focus() -> void:
	for index: int in _category_tabs.size():
		var tab: Button = _category_tabs[index]
		tab.focus_neighbor_left = tab.get_path_to(
			_category_tabs[maxi(index - 1, 0)]
		)
		tab.focus_neighbor_right = tab.get_path_to(
			_category_tabs[mini(index + 1, _category_tabs.size() - 1)]
		)


func _animate_tab_entry() -> void:
	for index: int in _category_tabs.size():
		(_category_tabs[index] as OrganizerTabType).animate_entrance(
			float(index) * 0.025
		)


func _settle_tabs_for_close() -> void:
	for tab: OrganizerTabType in _category_tabs:
		tab.settle_for_close()


func _begin_category_transition() -> void:
	_cancel_category_tween()
	_category_generation += 1
	var generation: int = _category_generation
	var restore_interactive: bool = _interactive
	set_interactive(false)
	_category_tween = create_tween()
	_category_tween.tween_method(
		_set_category_content_alpha,
		1.0,
		0.0,
		CATEGORY_FADE_DURATION * 0.5,
	).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
	_category_tween.tween_callback(
		func() -> void:
			if generation != _category_generation:
				return
			_refresh_catalog()
			_set_category_content_alpha(0.0)
	)
	_category_tween.tween_method(
		_set_category_content_alpha,
		0.0,
		1.0,
		CATEGORY_FADE_DURATION * 0.5,
	).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
	_category_tween.finished.connect(
		func() -> void:
			if generation != _category_generation:
				return
			_category_tween = null
			_set_category_content_alpha(1.0)
			set_interactive(restore_interactive)
			focus_initial(),
		CONNECT_ONE_SHOT,
	)


func _cancel_category_tween() -> void:
	_category_generation += 1
	if _category_tween != null:
		_category_tween.kill()
		_category_tween = null
	_set_category_content_alpha(1.0)


func _set_category_content_alpha(alpha: float) -> void:
	if _catalog_scroll != null:
		_catalog_scroll.modulate.a = alpha
	if _empty_state != null:
		_empty_state.modulate.a = alpha
	if _detail_body != null:
		_detail_body.modulate.a = alpha


func _select_entry(entry_key: StringName, fish_id: StringName) -> void:
	if fish_id.is_empty():
		_selected_id = StringName()
		_selected_entry_key = entry_key
		_refresh_selection_styles()
		_show_unknown()
		return
	if (
		_collection_log == null
		or not _collection_log.has_discovered(fish_id)
	):
		return
	_selected_id = fish_id
	_selected_entry_key = entry_key
	_refresh_selection_styles()
	_refresh_details(true)


func _refresh_details(animate: bool) -> void:
	if _selected_id.is_empty():
		_show_no_selection()
		return
	if (
		_collection_log == null
		or not _collection_log.has_discovered(_selected_id)
	):
		_show_unknown()
		return
	var fish: FishDataType = (
		_catalog.get_fish_by_id(_selected_id)
		if _catalog != null
		else null
	)
	if fish == null:
		_show_no_selection()
		return
	var rebuild: Callable = _build_known_details.bind(fish)
	if animate:
		_crossfade_details(rebuild)
	else:
		rebuild.call()


func _build_known_details(fish: FishDataType) -> void:
	_clear_details()
	var catalog_number: int = LogbookCatalog.catalog_number(fish.id)
	var summary_margin := MarginContainer.new()
	summary_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	_set_margins(summary_margin, 40, 0, 40, 0)
	_detail_body.add_child(summary_margin)
	var summary_columns := HBoxContainer.new()
	summary_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	summary_columns.add_theme_constant_override("separation", 24)
	summary_margin.add_child(summary_columns)

	var portrait_column := VBoxContainer.new()
	portrait_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	portrait_column.size_flags_stretch_ratio = 1.0
	portrait_column.add_theme_constant_override("separation", 4)
	summary_columns.add_child(portrait_column)
	var title := _label(fish.display_name, 26)
	title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	portrait_column.add_child(title)
	if fish.display_texture != null:
		_detail_portrait_button = Button.new()
		_detail_portrait_button.custom_minimum_size = DETAIL_PORTRAIT_SIZE
		_detail_portrait_button.size_flags_horizontal = (
			Control.SIZE_SHRINK_CENTER
		)
		_detail_portrait_button.clip_contents = true
		_detail_portrait_button.tooltip_text = "View larger artwork"
		_detail_portrait_button.accessibility_name = (
			"View larger artwork for %s" % fish.display_name
		)
		_detail_portrait_button.add_theme_stylebox_override(
			"normal", _portrait_button_style(false)
		)
		_detail_portrait_button.add_theme_stylebox_override(
			"hover", _portrait_button_style(true)
		)
		_detail_portrait_button.add_theme_stylebox_override(
			"focus", _portrait_button_style(true)
		)
		_detail_portrait_button.add_theme_stylebox_override(
			"pressed", _portrait_button_style(true)
		)
		_detail_portrait_button.pressed.connect(
			_show_portrait_overlay.bind(fish.display_texture)
		)
		portrait_column.add_child(_detail_portrait_button)
		var artwork_center := CenterContainer.new()
		artwork_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
		artwork_center.set_anchors_and_offsets_preset(
			Control.PRESET_FULL_RECT
		)
		_detail_portrait_button.add_child(artwork_center)
		var artwork := LogbookPortraitType.new()
		artwork.configure(
			fish.display_texture,
			DETAIL_PORTRAIT_SIZE,
		)
		artwork_center.add_child(artwork)

	var facts_column := VBoxContainer.new()
	facts_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	facts_column.size_flags_stretch_ratio = 1.0
	facts_column.add_theme_constant_override("separation", 6)
	summary_columns.add_child(facts_column)
	var facts_heading := _field_label("fish facts", 16)
	facts_column.add_child(facts_heading)
	var facts := _label(LogbookCatalog.facts_for(fish.id), 16)
	facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	facts_column.add_child(facts)
	_detail_body.add_child(_build_quality_progress(fish.id))

	var stats_anchor := VBoxContainer.new()
	stats_anchor.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	stats_anchor.size_flags_vertical = Control.SIZE_EXPAND_FILL
	stats_anchor.alignment = BoxContainer.ALIGNMENT_END
	_detail_body.add_child(stats_anchor)
	var stats_columns := HBoxContainer.new()
	stats_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	stats_columns.add_theme_constant_override("separation", 28)
	stats_anchor.add_child(stats_columns)
	var left_stats := _make_stats_column()
	var right_stats := _make_stats_column()
	stats_columns.add_child(left_stats)
	stats_columns.add_child(right_stats)
	_add_detail_row(
		left_stats,
		"catalog number",
		"#%03d" % catalog_number if catalog_number > 0 else "unknown",
	)
	_add_detail_row(
		left_stats,
		"body of water",
		WaterType.label(fish.get_primary_water_type()).to_lower(),
	)
	_add_detail_row(
		left_stats,
		"weight range",
		"%.2f–%.2f lb" % [
			fish.get_minimum_weight(),
			fish.get_maximum_weight(),
		],
	)
	_add_detail_row(left_stats, "number caught", "unknown")
	_add_detail_row(right_stats, "rarity", fish.get_rarity_name().to_lower())
	_add_detail_row(right_stats, "time of day", _availability_text(fish))
	_add_detail_row(
		right_stats,
		"value range",
		"%d–%d fish coin" % [
			FishQualityType.apply_sale_value(
				fish.sell_value_min,
				FishQualityType.Tier.BORING,
			),
			FishQualityType.apply_sale_value(
				fish.sell_value_max,
				FishQualityType.Tier.SHINY,
			),
		],
	)
	_add_detail_row(
		right_stats,
		"number owned",
		str(_inventory.get_count(fish.id) if _inventory != null else 0),
	)
	var bottom_inset := Control.new()
	bottom_inset.custom_minimum_size.y = DETAIL_BOTTOM_INSET
	bottom_inset.mouse_filter = Control.MOUSE_FILTER_IGNORE
	stats_anchor.add_child(bottom_inset)


func _build_quality_progress(fish_id: StringName) -> VBoxContainer:
	var quality_section := VBoxContainer.new()
	quality_section.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	quality_section.add_theme_constant_override("separation", 6)
	var discovered_count: int = 0
	for quality: int in FishQualityType.TIER_COUNT:
		if _collection_log.has_discovered_quality(fish_id, quality):
			discovered_count += 1
	var heading := _field_label(
		"quality collection • %d / %d" % [
			discovered_count,
			FishQualityType.TIER_COUNT,
		],
		17,
	)
	heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	heading.add_theme_color_override("font_color", MUTED_INK)
	quality_section.add_child(heading)
	var tiers := VBoxContainer.new()
	tiers.add_theme_constant_override("separation", 6)
	quality_section.add_child(tiers)
	for quality: int in FishQualityType.TIER_COUNT:
		var row: HBoxContainer
		if quality % 2 == 0:
			row = HBoxContainer.new()
			row.alignment = BoxContainer.ALIGNMENT_CENTER
			row.add_theme_constant_override("separation", 28)
			tiers.add_child(row)
		else:
			row = tiers.get_child(tiers.get_child_count() - 1) as HBoxContainer
		var discovered: bool = _collection_log.has_discovered_quality(
			fish_id,
			quality,
		)
		var tier := HBoxContainer.new()
		tier.alignment = BoxContainer.ALIGNMENT_CENTER
		tier.add_theme_constant_override("separation", 5)
		var quality_color: Color = UIPalette.get_quality_color(quality)
		var dot := _label("●" if discovered else "○", 17)
		dot.add_theme_color_override("font_color", quality_color)
		dot.modulate.a = 1.0 if discovered else 0.48
		tier.add_child(dot)
		var tier_label := _label(FishQualityType.display_name(quality), 16)
		tier_label.add_theme_color_override(
			"font_color",
			INK if discovered else MUTED_INK,
		)
		tier_label.modulate.a = 1.0 if discovered else 0.58
		tier.tooltip_text = (
			"%s quality collected"
			if discovered
			else "%s quality not yet collected"
		) % FishQualityType.display_name(quality)
		tier.add_child(tier_label)
		row.add_child(tier)
	return quality_section


func _show_no_selection() -> void:
	_clear_details()
	var instruction := _label("Select an entry to read its catch record.", 21)
	instruction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	instruction.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	instruction.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	instruction.size_flags_vertical = Control.SIZE_EXPAND_FILL
	_detail_body.add_child(instruction)


func _show_unknown() -> void:
	_clear_details()
	var unknown := _label("???", 34)
	unknown.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	_detail_body.add_child(unknown)
	var instruction := _label("Catch this creature to learn more.", 20)
	instruction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	instruction.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	_detail_body.add_child(instruction)


func _add_detail_row(
	parent: VBoxContainer,
	row_name: String,
	value: String,
) -> void:
	var row := VBoxContainer.new()
	row.add_theme_constant_override("separation", 2)
	var heading := _field_label(row_name, 14)
	heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	heading.add_theme_color_override("font_color", MUTED_INK)
	var content := _handwritten_value_label(value.to_lower(), 16)
	content.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	content.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	row.add_child(heading)
	row.add_child(content)
	parent.add_child(row)


func _make_stats_column() -> VBoxContainer:
	var column := VBoxContainer.new()
	column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	column.size_flags_stretch_ratio = 1.0
	column.add_theme_constant_override("separation", 12)
	return column


func _availability_text(fish: FishDataType) -> String:
	if fish.availability == null:
		return "unknown"
	if fish.availability.allow_day and fish.availability.allow_night:
		return "day and night"
	if fish.availability.allow_day:
		return "day"
	if fish.availability.allow_night:
		return "night"
	return "unknown"


func _crossfade_details(rebuild: Callable) -> void:
	_cancel_detail_tween()
	_detail_generation += 1
	var generation: int = _detail_generation
	_detail_tween = create_tween()
	_detail_tween.tween_property(
		_detail_body, "modulate:a", 0.0, DETAIL_FADE_DURATION * 0.5
	)
	_detail_tween.tween_callback(
		func() -> void:
			if generation == _detail_generation:
				rebuild.call()
	)
	_detail_tween.tween_property(
		_detail_body, "modulate:a", 1.0, DETAIL_FADE_DURATION * 0.5
	)
	_detail_tween.finished.connect(
		func() -> void:
			if generation == _detail_generation:
				_detail_tween = null,
		CONNECT_ONE_SHOT,
	)


func _cancel_detail_tween() -> void:
	_detail_generation += 1
	if _detail_tween != null:
		_detail_tween.kill()
		_detail_tween = null
	if _detail_body != null:
		_detail_body.modulate.a = 1.0


func _clear_entries() -> void:
	for child: Node in _catalog_grid.get_children():
		_catalog_grid.remove_child(child)
		child.queue_free()
	_entry_buttons.clear()


func _clear_details() -> void:
	_detail_portrait_button = null
	for child: Node in _detail_body.get_children():
		_detail_body.remove_child(child)
		child.queue_free()


func _refresh_selection_styles() -> void:
	for fish_id: StringName in _entry_buttons:
		_entry_buttons[fish_id].button_pressed = (
			fish_id == _selected_entry_key
		)


func _on_fish_discovered(fish_id: StringName) -> void:
	if _catalog == null:
		return
	var fish: FishDataType = _catalog.get_fish_by_id(fish_id)
	if fish == null:
		return
	if _selected_entry_key == _unknown_selection_key(fish):
		_selected_id = fish_id
		_selected_entry_key = fish_id
	_refresh_catalog()


func _on_collection_changed() -> void:
	_refresh_catalog()


func _on_inventory_changed() -> void:
	_refresh_details(false)


func _unknown_selection_key(fish: FishDataType) -> StringName:
	var number: int = LogbookCatalog.catalog_number(fish.id)
	return StringName("unknown_%d" % number)


func _label(text: String, font_size: int) -> Label:
	var label := Label.new()
	label.text = text
	label.add_theme_font_override(
		"font", InventoryNotepadType.HANDWRITTEN_FONT
	)
	label.add_theme_font_size_override("font_size", font_size)
	label.add_theme_color_override("font_color", INK)
	return label


func _field_label(text: String, font_size: int) -> Label:
	var label := Label.new()
	label.text = text
	label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
	label.add_theme_font_size_override("font_size", font_size)
	label.add_theme_color_override("font_color", INK)
	return label


func _handwritten_value_label(text: String, font_size: int) -> Label:
	var adjusted_size: int = font_size
	if _contains_ascii_digit(text):
		adjusted_size = roundi(
			float(font_size) * HANDWRITTEN_NUMERIC_SCALE
		)
	return _label(text, adjusted_size)


func _contains_ascii_digit(text: String) -> bool:
	for codepoint: int in text.to_utf32_buffer():
		if codepoint >= 48 and codepoint <= 57:
			return true
	return false


func _entry_label_text(entry_name: String) -> String:
	var split_index: int = entry_name.find(" ")
	if split_index < 0:
		return entry_name
	return (
		entry_name.substr(0, split_index)
		+ "\n"
		+ entry_name.substr(split_index + 1)
	)


func _build_portrait_overlay() -> void:
	_portrait_overlay = Control.new()
	_portrait_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	_portrait_overlay.z_index = 500
	_portrait_overlay.visible = false
	_portrait_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(_portrait_overlay)

	_portrait_overlay_backdrop = Button.new()
	_portrait_overlay_backdrop.set_anchors_and_offsets_preset(
		Control.PRESET_FULL_RECT
	)
	_portrait_overlay_backdrop.text = ""
	_portrait_overlay_backdrop.tooltip_text = "Return to logbook"
	_portrait_overlay_backdrop.accessibility_name = "Return to logbook"
	_portrait_overlay_backdrop.focus_neighbor_top = NodePath(".")
	_portrait_overlay_backdrop.focus_neighbor_bottom = NodePath(".")
	_portrait_overlay_backdrop.focus_neighbor_left = NodePath(".")
	_portrait_overlay_backdrop.focus_neighbor_right = NodePath(".")
	_portrait_overlay_backdrop.focus_next = NodePath(".")
	_portrait_overlay_backdrop.focus_previous = NodePath(".")
	var transparent_style := StyleBoxEmpty.new()
	for state: StringName in [
		&"normal", &"hover", &"focus", &"pressed", &"disabled",
	]:
		_portrait_overlay_backdrop.add_theme_stylebox_override(
			state, transparent_style
		)
	_portrait_overlay_backdrop.pressed.connect(_hide_portrait_overlay)
	_portrait_overlay_backdrop.gui_input.connect(
		_on_portrait_overlay_backdrop_input
	)
	_portrait_overlay.add_child(_portrait_overlay_backdrop)

	var center := CenterContainer.new()
	center.mouse_filter = Control.MOUSE_FILTER_IGNORE
	center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	_portrait_overlay.add_child(center)
	var card := PanelContainer.new()
	card.mouse_filter = Control.MOUSE_FILTER_STOP
	card.add_theme_stylebox_override("panel", _portrait_view_style())
	center.add_child(card)
	var margin := MarginContainer.new()
	_set_margins(margin, 22, 22, 22, 22)
	card.add_child(margin)
	_portrait_overlay_artwork = LogbookPortraitType.new()
	margin.add_child(_portrait_overlay_artwork)


func _show_portrait_overlay(portrait_texture: Texture2D) -> void:
	if portrait_texture == null or _portrait_overlay == null:
		return
	_portrait_overlay_artwork.configure_fitted(
		portrait_texture,
		PORTRAIT_VIEW_MAX_SIZE,
	)
	_portrait_overlay.visible = true
	_portrait_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
	_portrait_overlay_backdrop.grab_focus()


func _hide_portrait_overlay(restore_focus: bool = true) -> void:
	if _portrait_overlay == null or not _portrait_overlay.visible:
		return
	_portrait_overlay.visible = false
	_portrait_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
	if (
		restore_focus
		and _detail_portrait_button != null
		and is_instance_valid(_detail_portrait_button)
		and _active
		and _interactive
	):
		_detail_portrait_button.grab_focus()


func _on_portrait_overlay_backdrop_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_cancel"):
		_hide_portrait_overlay()
		get_viewport().set_input_as_handled()


func _portrait_button_style(highlighted: bool) -> StyleBoxFlat:
	var style := StyleBoxFlat.new()
	style.bg_color = (
		Color(1.0, 0.96, 0.86, 0.48)
		if highlighted
		else Color.TRANSPARENT
	)
	style.set_border_width_all(0)
	style.set_corner_radius_all(12)
	return style


func _portrait_view_style() -> StyleBoxFlat:
	var style := StyleBoxFlat.new()
	style.bg_color = Color("fff5dc")
	style.set_border_width_all(0)
	style.set_corner_radius_all(18)
	return style


func _entry_style(highlighted: bool) -> StyleBoxFlat:
	var style := StyleBoxFlat.new()
	style.bg_color = Color("fff5dc") if highlighted else Color.TRANSPARENT
	style.set_border_width_all(0)
	style.set_corner_radius_all(51)
	style.content_margin_left = 5
	style.content_margin_top = 5
	style.content_margin_right = 5
	style.content_margin_bottom = 5
	return style


func _set_margins(
	container: MarginContainer,
	left: int,
	top: int,
	right: int,
	bottom: int,
) -> void:
	container.add_theme_constant_override("margin_left", left)
	container.add_theme_constant_override("margin_top", top)
	container.add_theme_constant_override("margin_right", right)
	container.add_theme_constant_override("margin_bottom", bottom)
