@tool
@icon('TileMapDual.svg')
class_name TileMapDual
extends TileMapLayer


## An invisible material used to hide the world grid so only the display layers show up.
## Currently implemented as a shader that sets all pixels to 0 alpha.
var _ghost_material: Material = preload("res://addons/TileMapDual/ghost_material.tres")


# === External functions that don't exist once exported ===
# HACK: this uses some sort of "Dynamic Linking" technique because these features don't exist right now
# - conditional compilation
# - static signals
static func _editor_only(name: String):
	push_error('Attempt to call Editor-Only function "' + name + '"')
static var autotile: Callable = _editor_only.bind('autotile').unbind(3)
static var popup: Callable = _editor_only.bind('popup').unbind(2)


## Material for the display tilemap.
@export_custom(PROPERTY_HINT_RESOURCE_TYPE, "ShaderMaterial, CanvasItemMaterial")
var display_material: Material:
	get:
		return display_material
	set(new_material): # Custom setter so that it gets copied
		display_material = new_material
		changed.emit()

var _tileset_watcher: TileSetWatcher
var _display: Display
func _ready() -> void:
	_tileset_watcher = TileSetWatcher.new(tile_set)
	_display = Display.new(self, _tileset_watcher)
	add_child(_display)
	_make_self_invisible(true)
	if Engine.is_editor_hint():
		_tileset_watcher.atlas_autotiled.connect(_atlas_autotiled)
		set_process(true)
	else: # Run in-game using signals for better performance
		changed.connect(_changed, 1)
		set_process(false)
	# Update full tileset on first instance
	await get_tree().process_frame
	_changed()


## Automatically generate terrains when the atlas is initialized.
func _atlas_autotiled(source_id: int, atlas: TileSetAtlasSource):
	autotile.call(source_id, atlas, tile_set)
	

## Keeps track of use_parent_material to see when it turns on or off.
var _cached_use_parent_material = null
##[br] Makes the main world grid invisible.
##[br] The main tiles don't need to be seen. Only the DisplayLayers should be visible.
##[br] Called every frame, and functions a lot like TileSetWatcher.
func _make_self_invisible(startup: bool = false) -> void:
	# If user has set a material in the original slot, inform the user
	if material != _ghost_material:
		if not startup and Engine.is_editor_hint():
			popup.call(
				"Warning! Direct material edit detected.",
				"Don't manually edit the real material in the editor! Instead edit the custom 'Display Material' property.\n" +
				"(Resetting the material to an invisible shader material... this is to keep the 'World Layer' invisible)\n" +
				"* This warning is only given when the material is set in the Godot editor.\n" +
				"* In-game scripts may set the material directly. It will be copied over to display_material automatically."
			)
		else:
			# copy over the material if it was edited by script
			display_material = material
		material = _ghost_material # Force TileMapDual's material to become invisible
	
	# check if use_parent_material is set
	if (
		Engine.is_editor_hint()
		and use_parent_material != _cached_use_parent_material
		and _cached_use_parent_material == false # cache may be null
	):
		popup.call(
			"Warning: Using Parent Material.",
			"The parent material will override any other materials used by the TileMapDual,\n" +
			"including the 'ghost shader' that the world tiles use to hide themselves.\n" +
			"This will cause the world tiles to show themselves in-game.\n" +
			"\n" +
			"* Recommendation: Turn this setting off. Don't use parent material.\n" +
			"* Workaround: Set your world tiles to custom sprites that are entirely transparent.\n" +
			"(see 'res://addons/TileMapDual/docs/custom_drawing_sprites.mp4' for a non-transparent example)"
		)
	_cached_use_parent_material = use_parent_material


## HACK: How long to wait before processing another "frame".
## Mainly matters when [godot_4_3_compatibility] is active.
@export_range(0.0, 0.1) var refresh_time: float = 0.02
var _timer: float = 0.0
func _process(delta: float) -> void: # Only used inside the editor
	if refresh_time < 0.0:
		return
	if _timer > 0:
		_timer -= delta
		return
	_timer = refresh_time
	call_deferred('_changed')

## When toggled on, double-checks ALL cells in the grid every change.
## Only use this when running Godot 4.3 and below,
## where TileMapLayer could not detect changes properly.
@export var godot_4_3_compatibility: bool = _godot_is_below_4_4()

## Detects if godot is below v4.4.
## Only used to detect whether the _update_cells() function is usable.
func _godot_is_below_4_4():
	var version := Engine.get_version_info()
	return version.major < 4 or version.major == 4 and version.minor < 4

## Called by signals when the tileset changes,
## or by _process inside the editor.
func _changed() -> void:
	_tileset_watcher.update(tile_set)

	var updated_cells := []
	# HACK: double check all tiles every refresh
	if godot_4_3_compatibility and tile_set != null:
		var current_cells := TileCache.new()
		current_cells.update(self, get_used_cells())
		updated_cells = current_cells.xor(_display.cached_cells)

	_display.update(updated_cells)
	_make_self_invisible()


## Called when the user draws on the map or presses undo/redo.
func _update_cells(coords: Array[Vector2i], forced_cleanup: bool) -> void:
	if is_instance_valid(_display):
		_display.update(coords)


##[br] Public method to add and remove tiles.
##[br]
##[br] - 'cell' is a vector with the cell position.
##[br] - 'terrain' is which terrain type to draw.
##[br] - terrain -1 completely removes the tile,
##[br] - and by default, terrain 0 is the empty tile.
func draw_cell(cell: Vector2i, terrain: int = 1) -> void:
	var terrains := _display.terrain.terrains
	if terrain not in terrains:
		erase_cell(cell)
		changed.emit()
		return
	var tile_to_use: Dictionary = terrains[terrain]
	var sid: int = tile_to_use.sid
	var tile: Vector2i = tile_to_use.tile
	set_cell(cell, sid, tile)
	changed.emit()

## Public method to get the terrain at a specific coordinate.
func get_cell(cell: Vector2i) -> int:
	return get_cell_tile_data(cell).terrain
