@tool
@icon("res://addons/controller_icons/objects/controller_texture_icon.svg")
extends Texture2D
class_name ControllerIconTexture
## [Texture2D] proxy for displaying controller icons
##
## A 2D texture representing a controller icon. The underlying system provides
## a [Texture2D] that may react to changes in the current input method, and also detect the user's controller type.
## Specify the [member path] property to setup the desired icon and behavior.[br]
## [br]
## For a more technical overview, this resource functions as a proxy for any
## node that accepts a [Texture2D], redefining draw commands to use an
## underlying plain [Texture2D], which may be swapped by the remapping system.[br]
## [br]
## This resource works out-of-the box with many default nodes, such as [Sprite2D],
## [Sprite3D], [TextureRect], [RichTextLabel], and others. If you are
## integrating this resource on a custom node, you will need to connect to the
## [signal Resource.changed] signal to properly handle changes to the underlying
## texture. You might also need to force a redraw with methods such as
## [method CanvasItem.queue_redraw].
##
## @tutorial(Online documentation): https://github.com/rsubtil/controller_icons/blob/master/DOCS.md

## A path describing the desired icon. This is a generic path that can be one
## of three different types:
## [br][br]
## [b]- Input Action[/b]: Specify the exact name of an existing input action. The
## icon will be swapped automatically depending on whether the keyboard/mouse or the
## controller is being used. When using a controller, it also changes according to
## the controller type.[br][br]
## [i]This is the recommended approach, as it will handle all input methods
## automatically, and supports any input remapping done at runtime[/i].
## [codeblock]
## # "Enter" on keyboard, "Cross" on Sony,
## # "A" on Xbox, "B" on Nintendo
## path = "ui_accept"
## [/codeblock]
## [b]- Joypad Path[/b]: Specify a generic joypad path resembling the layout of a
## Xbox 360 controller, starting with the [code]joypad/[/code] prefix. The icon will only
## display controller icons, but it will still change according to the controller type.
## [codeblock]
## # "Square" on Sony, "X" on Xbox, "Y" on Nintendo
## path = "joypad/x"
## [/codeblock]
## [b]- Specific Path[/b]: Specify a direct asset path from the addon assets.
## With this path type, there is no dynamic remapping, and the icon will always
## remain the same. The path to use is the path to an icon file, minus the base
## path and extension.
## [codeblock]
## # res://addons/controller_icons/assets/steam/gyro.png
## path = "steam/gyro"
## [/codeblock]
@export var path: String = "":
	set(_path):
		path = _path
		_load_texture_path()

enum ShowMode {
	ANY, ## Icon will be display on any input method.
	KEYBOARD_MOUSE, ## Icon will be display only when the keyboard/mouse is being used.
	CONTROLLER ## Icon will be display only when a controller is being used.
}

## Show the icon only if a specific input method is being used. When hidden, 
## the icon will not occupy have any space (no width and height).
@export var show_mode: ShowMode = ShowMode.ANY:
	set(_show_mode):
		show_mode = _show_mode
		_load_texture_path()


## Forces the icon to show a specific controller style, regardless of the
## currently used controller type.
##[br][br]
## This will override force_device if set to a value other than NONE.
##[br][br]
## This is only relevant for paths using input actions, and has no effect on
## other scenarios.

@export var force_controller_icon_style: ControllerSettings.Devices = ControllerSettings.Devices.NONE:
	set(_force_controller_icon_style):
		force_controller_icon_style = _force_controller_icon_style
		_load_texture_path()

enum ForceType {
	NONE, ## Icon will swap according to the used input method.
	KEYBOARD_MOUSE, ## Icon will always show the keyboard/mouse action.
	CONTROLLER, ## Icon will always show the controller action.
}

## Forces the icon to show either the keyboard/mouse or controller icon,
## regardless of the currently used input method.
##[br][br]
## This is only relevant for paths using input actions, and has no effect on
## other scenarios.
@export var force_type: ForceType = ForceType.NONE:
	set(_force_type):
		force_type = _force_type
		_load_texture_path()

enum ForceDevice {
	DEVICE_0,
	DEVICE_1,
	DEVICE_2,
	DEVICE_3,
	DEVICE_4,
	DEVICE_5,
	DEVICE_6,
	DEVICE_7,
	DEVICE_8,
	DEVICE_9,
	DEVICE_10,
	DEVICE_11,
	DEVICE_12,
	DEVICE_13,
	DEVICE_14,
	DEVICE_15,
	ANY # No device will be forced
}

## Forces the icon to use the textures for the device connected at the specified index.
## For example, if a PlayStation 5 controller is connected at device_index 0,
## the icon will always show PlayStation 5 textures.
@export var force_device: ForceDevice = ForceDevice.ANY:
	set(_force_device):
		force_device = _force_device
		_load_texture_path()

@export_subgroup("Text Rendering")
## Custom LabelSettings. If set, overrides the addon's global label settings.
@export var custom_label_settings: LabelSettings:
	set(_custom_label_settings):
		custom_label_settings = _custom_label_settings
		_load_texture_path()

		# Call _textures setter, which handles signal connections for label settings
		_textures = _textures


## Returns a text representation of the displayed icon, useful for TTS
## (text-to-speech) scenarios.
## [br][br]
## This takes into consideration the currently displayed icon, and will thus be
## different if the icon is from keyboard/mouse or controller. It also takes
## into consideration the controller type, and will thus use native button
## names (e.g. [code]A[/code] for Xbox, [code]Cross[/code] for PlayStation, etc).
func get_tts_string() -> String:
	if force_type:
		return ControllerIcons.parse_path_to_tts(path, force_type - 1)
	else:
		return ControllerIcons.parse_path_to_tts(path)

func _can_be_shown():
	match show_mode:
		1:
			return ControllerIcons._last_input_type == ControllerIcons.InputType.KEYBOARD_MOUSE
		2:
			return ControllerIcons._last_input_type == ControllerIcons.InputType.CONTROLLER
		0, _:
			return true

var _textures: Array[Texture2D]:
	set(__textures):
		# UPGRADE: In Godot 4.2, for-loop variables can be
		# statically typed:
		# for tex:Texture in __textures:
		for tex in __textures:
			if tex and tex.is_connected("changed", _reload_resource):
				tex.disconnect("changed", _reload_resource)

		if _label_settings and _label_settings.is_connected("changed", _on_label_settings_changed):
			_label_settings.disconnect("changed", _on_label_settings_changed)

		_textures = __textures
		_label_settings = null
		if _textures and _textures.size() > 1:
			_label_settings = custom_label_settings
			if not _label_settings:
				_label_settings = ControllerIcons._settings.custom_label_settings
			if not _label_settings:
				_label_settings = LabelSettings.new()
			_label_settings.connect("changed", _on_label_settings_changed)
			_font = ThemeDB.fallback_font if not _label_settings.font else _label_settings.font
			_on_label_settings_changed()
		# UPGRADE: In Godot 4.2, for-loop variables can be
		# statically typed:
		# for tex:Texture in __textures:
		for tex in __textures:
			if tex:
				tex.connect("changed", _reload_resource)

var _font: Font
var _label_settings: LabelSettings
var _text_size: Vector2

func _on_label_settings_changed():
	_font = ThemeDB.fallback_font if not _label_settings.font else _label_settings.font
	_text_size = _font.get_string_size("+", HORIZONTAL_ALIGNMENT_LEFT, -1, _label_settings.font_size)
	_reload_resource()

func _reload_resource():
	_dirty = true
	emit_changed()

func _load_texture_path_impl():
	var textures: Array[Texture2D] = []
	if ControllerIcons.is_node_ready() and _can_be_shown():
		var input_type = ControllerIcons._last_input_type if force_type == ForceType.NONE else force_type - 1
		if ControllerIcons.get_path_type(path) == ControllerIcons.PathType.INPUT_ACTION:
			var event := ControllerIcons.get_matching_event(path, input_type)
			textures.append_array(ControllerIcons.parse_event_modifiers(event))
		var target_device = force_device if force_device != ForceDevice.ANY else ControllerIcons._last_controller
		var tex := ControllerIcons.parse_path(path, input_type, target_device, force_controller_icon_style)
		if tex:
			textures.append(tex)
	_textures = textures
	_reload_resource()

func _load_texture_path():
	# Ensure loading only occurs on the main thread
	if OS.get_thread_caller_id() != OS.get_main_thread_id():
		# In Godot 4.3, call_deferred no longer makes this function
		# execute on the main thread due to changes in resource loading.
		# To ensure this, we instead rely on ControllerIcons for this
		ControllerIcons._defer_texture_load(_load_texture_path_impl)
	else:
		_load_texture_path_impl()

func _init():
	ControllerIcons.input_type_changed.connect(_on_input_type_changed)

func _on_input_type_changed(input_type: int, controller: int):
	_load_texture_path()

#region "Draw functions"
const _NULL_SIZE := 2

func _get_width() -> int:
	if _can_be_shown():
		var ret := _textures.reduce(func(accum: int, texture: Texture2D):
			if texture:
				return accum + texture.get_width()
			return accum
		, 0)
		if _label_settings:
			ret += max(0, _textures.size() - 1) * _text_size.x
		# If ret is 0, return a size of 2 to prevent triggering engine checks
		# for null sizes. The correct size will be set at a later frame.
		return ret if ret > 0 else _NULL_SIZE
	return _NULL_SIZE

func _get_height() -> int:
	if _can_be_shown():
		var ret := _textures.reduce(func(accum: int, texture: Texture2D):
			if texture:
				return max(accum, texture.get_height())
			return accum
		, 0)
		if _label_settings and _textures.size() > 1:
			ret = max(ret, _text_size.y)
		# If ret is 0, return a size of 2 to prevent triggering engine checks
		# for null sizes. The correct size will be set at a later frame.
		return ret if ret > 0 else _NULL_SIZE
	return _NULL_SIZE

func _has_alpha() -> bool:
	return _textures.any(func(texture: Texture2D):
		return texture.has_alpha()
	)

func _is_pixel_opaque(x, y) -> bool:
	# TODO: Not exposed to GDScript; however, since this seems to be used for editor stuff, it's
	# seemingly fine to just report all pixels as opaque. Otherwise, mouse picking for Sprite2D
	# stops working.
	return true

func _draw(to_canvas_item: RID, pos: Vector2, modulate: Color, transpose: bool):
	var position := pos

	for i in range(_textures.size()):
		var tex: Texture2D = _textures[i]
		if !tex: continue

		if i != 0:
			# Draw text char '+'
			var font_position := Vector2(
				position.x,
				position.y + (get_height() - _text_size.y) / 2.0
			)
			_draw_text(to_canvas_item, font_position, "+")
			position.x += _text_size.x

		tex.draw(to_canvas_item, position, modulate, transpose)
		position.x += tex.get_width()

func _draw_rect(to_canvas_item: RID, rect: Rect2, tile: bool, modulate: Color, transpose: bool):
	var position := rect.position
	var width_ratio := rect.size.x / _get_width()
	var height_ratio := rect.size.y / _get_height()

	for i in range(_textures.size()):
		var tex: Texture2D = _textures[i]
		if !tex: continue

		if i != 0:
			# Draw text char '+'
			var font_position := Vector2(
				position.x + (_text_size.x * width_ratio) / 2 - (_text_size.x / 2),
				position.y + (rect.size.y - _text_size.y) / 2.0
			)
			_draw_text(to_canvas_item, font_position, "+")
			position.x += _text_size.x * width_ratio

		var size := tex.get_size() * Vector2(width_ratio, height_ratio)
		tex.draw_rect(to_canvas_item, Rect2(position, size), tile, modulate, transpose)
		position.x += size.x

func _draw_rect_region(to_canvas_item: RID, rect: Rect2, src_rect: Rect2, modulate: Color, transpose: bool, clip_uv: bool):
	var position := rect.position
	var width_ratio := rect.size.x / _get_width()
	var height_ratio := rect.size.y / _get_height()

	for i in range(_textures.size()):
		var tex: Texture2D = _textures[i]
		if !tex: continue

		if i != 0:
			# Draw text char '+'
			var font_position := Vector2(
				position.x + (_text_size.x * width_ratio) / 2 - (_text_size.x / 2),
				position.y + (rect.size.y - _text_size.y) / 2.0
			)
			_draw_text(to_canvas_item, font_position, "+")
			position.x += _text_size.x * width_ratio

		var size := tex.get_size() * Vector2(width_ratio, height_ratio)
		var src_rect_ratio := Vector2(
			tex.get_width() / float(_get_width()),
			tex.get_height() / float(_get_height())
		)
		var tex_src_rect := Rect2(
			src_rect.position * src_rect_ratio,
			src_rect.size * src_rect_ratio
		)

		tex.draw_rect_region(to_canvas_item, Rect2(position, size), tex_src_rect, modulate, transpose, clip_uv)
		position.x += size.x

func _draw_text(to_canvas_item: RID, font_position: Vector2, text: String):
	font_position.y += _font.get_ascent(_label_settings.font_size)
	
	if _label_settings.shadow_color.a > 0:
		_font.draw_string(to_canvas_item, font_position + _label_settings.shadow_offset, text, HORIZONTAL_ALIGNMENT_LEFT, -1, _label_settings.font_size, _label_settings.shadow_color)
		if _label_settings.shadow_size > 0:
			_font.draw_string_outline(to_canvas_item, font_position + _label_settings.shadow_offset, text, HORIZONTAL_ALIGNMENT_LEFT, -1, _label_settings.font_size, _label_settings.shadow_size, _label_settings.shadow_color)
	if _label_settings.outline_color.a > 0 and _label_settings.outline_size > 0:
			_font.draw_string_outline(to_canvas_item, font_position, text, HORIZONTAL_ALIGNMENT_LEFT, -1, _label_settings.font_size, _label_settings.outline_size, _label_settings.outline_color)
	_font.draw_string(to_canvas_item, font_position, text, HORIZONTAL_ALIGNMENT_CENTER, -1, _label_settings.font_size, _label_settings.font_color)

var _helper_viewport: Viewport
var _is_stitching_texture: bool = false
func _stitch_texture():
	if _textures.is_empty():
		return

	_is_stitching_texture = true

	var font_image: Image
	if _textures.size() > 1:
		# Generate a viewport to draw the text
		_helper_viewport = SubViewport.new()
		# FIXME: We need a 3px margin for some reason
		_helper_viewport.size = _text_size + Vector2(3, 0)
		_helper_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
		_helper_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
		_helper_viewport.transparent_bg = true

		var label := Label.new()
		label.label_settings = _label_settings
		label.text = "+"
		label.position = Vector2.ZERO
		_helper_viewport.add_child(label)

		ControllerIcons.add_child(_helper_viewport)
		await RenderingServer.frame_post_draw
		font_image = _helper_viewport.get_texture().get_image()
		ControllerIcons.remove_child(_helper_viewport)
		_helper_viewport.free()

	var position := Vector2i(0, 0)
	var img: Image
	for i in range(_textures.size()):
		if !_textures[i]: continue

		if i != 0:
			# Draw text char '+'
			var region := font_image.get_used_rect()
			var font_position := Vector2i(
				position.x,
				position.y + (get_height() - region.size.y) / 2
			)
			img.blit_rect(font_image, region, font_position)
			position.x += ceili(region.size.x)

		var texture_raw := _textures[i].get_image()
		texture_raw.decompress()
		if not img:
			img = Image.create(_get_width(), _get_height(), true, texture_raw.get_format())

		img.blit_rect(texture_raw, Rect2i(0, 0, texture_raw.get_width(), texture_raw.get_height()), position)
		position.x += texture_raw.get_width()

	_is_stitching_texture = false
	_dirty = false
	_texture_3d = ImageTexture.create_from_image(img)
	emit_changed()

# This is necessary for 3D sprites, as the texture is assigned to a material, and not drawn directly.
# For multi prompts, we need to generate a texture
var _dirty := true
var _texture_3d: Texture
func _get_rid():
	if _dirty:
		if not _is_stitching_texture:
			# FIXME: Function may await, but because this is an internal engine call, we can't do anything about it.
			# This results in a one-frame white texture being displayed, which is not ideal. Investigate later.
			_stitch_texture()
			if _is_stitching_texture:
				return 0
		else:
			return 0
	return _texture_3d.get_rid() if not _textures.is_empty() else 0

#endregion
