@tool
extends Node

## A [TileMapLayer] terrain / auto-tiling system.
##
## This is a drop-in replacement for Godot 4's tilemap terrain system, offering
## more versatile and straightforward autotiling. It can be used with any
## existing [TileMapLayer] or [TileSet], either through the editor plugin, or
## directly via code.
## [br][br]
## The [b]BetterTerrain[/b] class contains only static functions, each of which
## either takes a [TileMapLayer], a [TileSet], and sometimes a [TileData].
## Meta-data is embedded inside the [TileSet] and the [TileData] types to store
## the terrain information. See [method Object.get_meta] for information.
## [br][br]
## Once terrain is set up, it can be written to the tilemap using [method set_cells].
## Similar to Godot 3.x, setting the cells does not run the terrain solver, so once
## the cells have been set, you need to call an update function such as [method update_terrain_cells].


## The meta-data key used to store terrain information.
const TERRAIN_META = &"_better_terrain"

## The current version. Used to handle future upgrades.
const TERRAIN_SYSTEM_VERSION = "0.2"

var _tile_cache = {}
var rng = RandomNumberGenerator.new()
var use_seed := true

## A helper class that provides functions detailing valid peering bits and
## polygons for different tile types.
var data := load("res://addons/better-terrain/BetterTerrainData.gd"):
	get:
		return data

enum TerrainType {
	MATCH_TILES, ## Selects tiles by matching against adjacent tiles.
	MATCH_VERTICES, ## Select tiles by analysing vertices, similar to wang-style tiles.
	CATEGORY, ## Declares a matching type for more sophisticated rules.
	DECORATION, ## Fills empty tiles by matching adjacent tiles
	MAX,
}

enum TileCategory {
	EMPTY = -1, ## An empty cell, or a tile marked as decoration
	NON_TERRAIN = -2, ## A non-empty cell that does not contain a terrain tile
	ERROR = -3
}

enum SymmetryType {
	NONE,
	MIRROR, ## Horizontally mirror
	FLIP, ## Vertically flip
	REFLECT, ## All four reflections
	ROTATE_CLOCKWISE,
	ROTATE_COUNTER_CLOCKWISE,
	ROTATE_180,
	ROTATE_ALL, ## All four rotated forms
	ALL ## All rotated and reflected forms
}


func _intersect(first: Array, second: Array) -> bool:
	if first.size() > second.size():
		return _intersect(second, first) # Array 'has' is fast compared to gdscript loop
	for f in first:
		if second.has(f):
			return true
	return false


# Meta-data functions

func _get_terrain_meta(ts: TileSet) -> Dictionary:
	return ts.get_meta(TERRAIN_META) if ts and ts.has_meta(TERRAIN_META) else {
		terrains = [],
		decoration = ["Decoration", Color.DIM_GRAY, TerrainType.DECORATION, [], {path = "res://addons/better-terrain/icons/Decoration.svg"}],
		version = TERRAIN_SYSTEM_VERSION
	}


func _set_terrain_meta(ts: TileSet, meta : Dictionary) -> void:
	ts.set_meta(TERRAIN_META, meta)
	ts.emit_changed()


func _get_tile_meta(td: TileData) -> Dictionary:
	return td.get_meta(TERRAIN_META) if td.has_meta(TERRAIN_META) else {
		type = TileCategory.NON_TERRAIN
	}


func _set_tile_meta(ts: TileSet, td: TileData, meta) -> void:
	td.set_meta(TERRAIN_META, meta)
	ts.emit_changed()


func _get_cache(ts: TileSet) -> Array:
	if _tile_cache.has(ts):
		return _tile_cache[ts]
	
	var cache := []
	if !ts:
		return cache
	_tile_cache[ts] = cache

	var watcher = Node.new()
	watcher.set_script(load("res://addons/better-terrain/Watcher.gd"))
	watcher.tileset = ts
	watcher.trigger.connect(_purge_cache.bind(ts))
	add_child(watcher)
	ts.changed.connect(watcher.activate)
	
	var types = {}
	
	var ts_meta := _get_terrain_meta(ts)
	for t in ts_meta.terrains.size():
		var terrain = ts_meta.terrains[t]
		var bits = terrain[3].duplicate()
		bits.push_back(t)
		types[t] = bits
		cache.push_back([])
	
	# Decoration
	types[-1] = [TileCategory.EMPTY]
	cache.push_back([[-1, Vector2.ZERO, -1, {}, 1.0]])
	
	for s in ts.get_source_count():
		var source_id := ts.get_source_id(s)
		var source := ts.get_source(source_id) as TileSetAtlasSource
		if !source:
			continue
		source.changed.connect(watcher.activate)
		for c in source.get_tiles_count():
			var coord := source.get_tile_id(c)
			for a in source.get_alternative_tiles_count(coord):
				var alternate := source.get_alternative_tile_id(coord, a)
				var td := source.get_tile_data(coord, alternate)
				var td_meta := _get_tile_meta(td)
				if td_meta.type < TileCategory.EMPTY or td_meta.type >= cache.size():
					continue
				
				td.changed.connect(watcher.activate)
				var peering := {}
				for key in td_meta.keys():
					if !(key is int):
						continue
					
					var targets := []
					for k in types:
						if _intersect(types[k], td_meta[key]):
							targets.push_back(k)
					
					peering[key] = targets
				
				# Decoration tiles without peering are skipped
				if td_meta.type == TileCategory.EMPTY and !peering:
					continue
				
				var symmetry = td_meta.get("symmetry", SymmetryType.NONE)
				# Branch out no symmetry tiles early
				if symmetry == SymmetryType.NONE:
					cache[td_meta.type].push_back([source_id, coord, alternate, peering, td.probability])
					continue
				
				# calculate the symmetry order for this tile
				var symmetry_order := 0
				for flags in data.symmetry_mapping[symmetry]:
					var symmetric_peering = data.peering_bits_after_symmetry(peering, flags)
					if symmetric_peering == peering:
						symmetry_order += 1
				
				var adjusted_probability = td.probability / symmetry_order
				for flags in data.symmetry_mapping[symmetry]:
					var symmetric_peering = data.peering_bits_after_symmetry(peering, flags)
					cache[td_meta.type].push_back([source_id, coord, alternate | flags, symmetric_peering, adjusted_probability])
	
	return cache


func _get_cache_terrain(ts_meta : Dictionary, index: int) -> Array:
	# the cache and the terrains in ts_meta don't line up because
	# decorations are cached too
	if index < 0 or index >= ts_meta.terrains.size():
		return ts_meta.decoration
	return ts_meta.terrains[index]


func _purge_cache(ts: TileSet) -> void:
	_tile_cache.erase(ts)
	for c in get_children():
		if c.tileset == ts:
			c.tidy()
			break


func _clear_invalid_peering_types(ts: TileSet) -> void:
	var ts_meta := _get_terrain_meta(ts)
	
	var cache := _get_cache(ts)
	for t in cache.size():
		var type = _get_cache_terrain(ts_meta, t)[2]
		var valid_peering_types = data.get_terrain_peering_cells(ts, type)
		
		for c in cache[t]:
			if c[0] < 0:
				continue
			var source := ts.get_source(c[0]) as TileSetAtlasSource
			if !source:
				continue
			var td := source.get_tile_data(c[1], c[2])
			var td_meta := _get_tile_meta(td)
			
			for peering in c[3].keys():
				if valid_peering_types.has(peering):
					continue
				td_meta.erase(peering)
			
			_set_tile_meta(ts, td, td_meta)
	
	# Not strictly necessary
	_purge_cache(ts)


func _has_invalid_peering_types(ts: TileSet) -> bool:
	var ts_meta := _get_terrain_meta(ts)
	
	var cache := _get_cache(ts)
	for t in cache.size():
		var type = _get_cache_terrain(ts_meta, t)[2]
		var valid_peering_types = data.get_terrain_peering_cells(ts, type)
		
		for c in cache[t]:
			for peering in c[3].keys():
				if !valid_peering_types.has(peering):
					return true
	
	return false


func _update_terrain_data(ts: TileSet) -> void:
	var ts_meta = _get_terrain_meta(ts)
	var previous_version = ts_meta.get("version")
	
	# First release: no version info
	if !ts_meta.has("version"):
		ts_meta["version"] = "0.0"
	
	# 0.0 -> 0.1: add categories
	if ts_meta.version == "0.0":
		for t in ts_meta.terrains:
			if t.size() == 3:
				t.push_back([])
		ts_meta.version = "0.1"
	
	# 0.1 -> 0.2: add decoration tiles and terrain icons
	if ts_meta.version == "0.1":
		# Add terrain icon containers
		for t in ts_meta.terrains:
			if t.size() == 4:
				t.push_back({})
		
		# Add default decoration data
		ts_meta["decoration"] = ["Decoration", Color.DIM_GRAY, TerrainType.DECORATION, [], {path = "res://addons/better-terrain/icons/Decoration.svg"}]
		ts_meta.version = "0.2"
	
	if previous_version != ts_meta.version:
		_set_terrain_meta(ts, ts_meta)


func _weighted_selection(choices: Array, apply_empty_probability: bool):
	if choices.is_empty():
		return null
	
	var weight = choices.reduce(func(a, c): return a + c[4], 0.0)
	
	if apply_empty_probability and weight < 1.0 and rng.randf() > weight:
		return [-1, Vector2.ZERO, -1, null, 1.0]
	
	if choices.size() == 1:
		return choices[0]
	
	if weight == 0.0:
		return choices[rng.randi() % choices.size()]
	
	var pick = rng.randf() * weight
	for c in choices:
		if pick < c[4]:
			return c
		pick -= c[4]
	return choices.back()


func _weighted_selection_seeded(choices: Array, coord: Vector2i, apply_empty_probability: bool):
	if use_seed:
		rng.seed = hash(coord)
	return _weighted_selection(choices, apply_empty_probability)


func _update_tile_tiles(tm: TileMapLayer, coord: Vector2i, types: Dictionary, cache: Array, apply_empty_probability: bool):
	var type = types[coord]
	
	const reward := 3
	var penalty := -2000 if apply_empty_probability else -10
	
	var best_score := -1000 # Impossibly bad score
	var best := []
	for t in cache[type]:
		var score := 0
		for peering in t[3]:
			score += reward if t[3][peering].has(types[tm.get_neighbor_cell(coord, peering)]) else penalty
		if score > best_score:
			best_score = score
			best = [t]
		elif score == best_score:
			best.append(t)
	
	return _weighted_selection_seeded(best, coord, apply_empty_probability)


func _probe(tm: TileMapLayer, coord: Vector2i, peering: int, type: int, types: Dictionary) -> int:
	var targets = data.associated_vertex_cells(tm, coord, peering)
	targets = targets.map(func(c): return types[c])
	
	var first = targets[0]
	if targets.all(func(t): return t == first):
		return first
	
	# if different, use the lowest  non-same
	targets = targets.filter(func(t): return t != type)
	return targets.reduce(func(a, t): return min(a, t))


func _update_tile_vertices(tm: TileMapLayer, coord: Vector2i, types: Dictionary, cache: Array):
	var type = types[coord]
	
	const reward := 3
	const penalty := -10
	
	var best_score := -1000 # Impossibly bad score
	var best := []
	for t in cache[type]:
		var score := 0
		for peering in t[3]:
			score += reward if _probe(tm, coord, peering, type, types) in t[3][peering] else penalty
		
		if score > best_score:
			best_score = score
			best = [t]
		elif score == best_score:
			best.append(t)
	
	return _weighted_selection_seeded(best, coord, false)


func _update_tile_immediate(tm: TileMapLayer, coord: Vector2i, ts_meta: Dictionary, types: Dictionary, cache: Array) -> void:
	var type = types[coord]
	if type < TileCategory.EMPTY or type >= ts_meta.terrains.size():
		return
	
	var placement
	var terrain = _get_cache_terrain(ts_meta, type)
	if terrain[2] in [TerrainType.MATCH_TILES, TerrainType.DECORATION]:
		placement = _update_tile_tiles(tm, coord, types, cache, true)
	elif terrain[2] == TerrainType.MATCH_VERTICES:
		placement = _update_tile_vertices(tm, coord, types, cache)
	else:
		return
	
	if placement:
		tm.set_cell(coord, placement[0], placement[1], placement[2])


func _update_tile_deferred(tm: TileMapLayer, coord: Vector2i, ts_meta: Dictionary, types: Dictionary, cache: Array):
	var type = types[coord]
	if type >= TileCategory.EMPTY and type < ts_meta.terrains.size():
		var terrain = _get_cache_terrain(ts_meta, type)
		if terrain[2] in [TerrainType.MATCH_TILES, TerrainType.DECORATION]:
			return _update_tile_tiles(tm, coord, types, cache, terrain[2] == TerrainType.DECORATION)
		elif terrain[2] == TerrainType.MATCH_VERTICES:
			return _update_tile_vertices(tm, coord, types, cache)
	return null


func _widen(tm: TileMapLayer, coords: Array) -> Array:
	var result := {}
	var peering_neighbors = data.get_terrain_peering_cells(tm.tile_set, TerrainType.MATCH_TILES)
	for c in coords:
		result[c] = true
		var neighbors = data.neighboring_coords(tm, c, peering_neighbors)
		for t in neighbors:
			result[t] = true
	return result.keys()


func _widen_with_exclusion(tm: TileMapLayer, coords: Array, exclusion: Rect2i) -> Array:
	var result := {}
	var peering_neighbors = data.get_terrain_peering_cells(tm.tile_set, TerrainType.MATCH_TILES)
	for c in coords:
		if !exclusion.has_point(c):
			result[c] = true
		var neighbors = data.neighboring_coords(tm, c, peering_neighbors)
		for t in neighbors:
			if !exclusion.has_point(t):
				result[t] = true
	return result.keys()

# Terrains

## Returns an [Array] of categories. These are the terrains in the [TileSet] which
## are marked with [enum TerrainType] of [code]CATEGORY[/code]. Each entry in the
## array is a [Dictionary] with [code]name[/code], [code]color[/code], and [code]id[/code].
func get_terrain_categories(ts: TileSet) -> Array:
	var result := []
	if !ts:
		return result
	
	var ts_meta := _get_terrain_meta(ts)
	for id in ts_meta.terrains.size():
		var t = ts_meta.terrains[id]
		if t[2] == TerrainType.CATEGORY:
			result.push_back({name = t[0], color = t[1], id = id})
	
	return result


## Adds a new terrain to the [TileSet]. Returns [code]true[/code] if this is successful.
## [br][br]
## [code]type[/code] must be one of [enum TerrainType].[br]
## [code]categories[/code] is an indexed list of terrain categories that this terrain
## can match as. The indexes must be valid terrains of the CATEGORY type.
## [code]icon[/code] is a [Dictionary] with either a [code]path[/code] string pointing
## to a resource, or a [code]source_id[/code] [int] and a [code]coord[/code] [Vector2i].
## The former takes priority if both are present.
func add_terrain(ts: TileSet, name: String, color: Color, type: int, categories: Array = [], icon: Dictionary = {}) -> bool:
	if !ts or name.is_empty() or type < 0 or type == TerrainType.DECORATION or type >= TerrainType.MAX:
		return false
	
	var ts_meta := _get_terrain_meta(ts)
	
	# check categories
	if type == TerrainType.CATEGORY and !categories.is_empty():
		return false
	for c in categories:
		if c < 0 or c >= ts_meta.terrains.size() or ts_meta.terrains[c][2] != TerrainType.CATEGORY:
			return false
	
	if icon and not (icon.has("path") or (icon.has("source_id") and icon.has("coord"))):
		return false
	
	ts_meta.terrains.push_back([name, color, type, categories, icon])
	_set_terrain_meta(ts, ts_meta)
	_purge_cache(ts)
	return true


## Removes the terrain at [code]index[/code] from the [TileSet]. Returns [code]true[/code]
## if the deletion is successful.
func remove_terrain(ts: TileSet, index: int) -> bool:
	if !ts or index < 0:
		return false
	
	var ts_meta := _get_terrain_meta(ts)
	if index >= ts_meta.terrains.size():
		return false
	
	if ts_meta.terrains[index][2] == TerrainType.CATEGORY:
		for t in ts_meta.terrains:
			t[3].erase(index)
	
	for s in ts.get_source_count():
		var source := ts.get_source(ts.get_source_id(s)) as TileSetAtlasSource
		if !source:
			continue
		for t in source.get_tiles_count():
			var coord := source.get_tile_id(t)
			for a in source.get_alternative_tiles_count(coord):
				var alternate := source.get_alternative_tile_id(coord, a)
				var td := source.get_tile_data(coord, alternate)
				
				var td_meta := _get_tile_meta(td)
				if td_meta.type == TileCategory.NON_TERRAIN:
					continue
				
				if td_meta.type == index:
					_set_tile_meta(ts, td, null)
					continue
				
				if td_meta.type > index:
					td_meta.type -= 1
				
				for peering in td_meta.keys():
					if !(peering is int):
						continue
					
					var fixed_peering = []
					for p in td_meta[peering]:
						if p < index:
							fixed_peering.append(p)
						elif p > index:
							fixed_peering.append(p - 1)
					
					if fixed_peering.is_empty():
						td_meta.erase(peering)
					else:
						td_meta[peering] = fixed_peering
				
				_set_tile_meta(ts, td, td_meta)
	
	ts_meta.terrains.remove_at(index)
	_set_terrain_meta(ts, ts_meta)
	
	_purge_cache(ts)	
	return true


## Returns the number of terrains in the [TileSet].
func terrain_count(ts: TileSet) -> int:
	if !ts:
		return 0
	
	var ts_meta := _get_terrain_meta(ts)
	return ts_meta.terrains.size()


## Retrieves information about the terrain at [code]index[/code] in the [TileSet].
## [br][br]
## Returns a [Dictionary] describing the terrain. If it succeeds, the key [code]valid[/code]
## will be set to [code]true[/code]. Other keys are [code]name[/code], [code]color[/code],
## [code]type[/code] (a [enum TerrainType]), [code]categories[/code] which is
## an [Array] of category type terrains that this terrain matches as, and
## [code]icon[/code] which is a [Dictionary] with a [code]path[/code] [String] or
## a [code]source_id[/code] [int] and [code]coord[/code] [Vector2i]
func get_terrain(ts: TileSet, index: int) -> Dictionary:
	if !ts or index < TileCategory.EMPTY:
		return {valid = false}
	
	var ts_meta := _get_terrain_meta(ts)
	if index >= ts_meta.terrains.size():
		return {valid = false}
	
	var terrain := _get_cache_terrain(ts_meta, index)
	return {
		id = index,
		name = terrain[0],
		color = terrain[1],
		type = terrain[2],
		categories = terrain[3].duplicate(),
		icon = terrain[4].duplicate(),
		valid = true
	}


## Updates the details of the terrain at [code]index[/code] in [TileSet]. Returns
## [code]true[/code] if this succeeds.
## [br][br]
## If supplied, the [code]categories[/code] must be a list of indexes to other [code]CATEGORY[/code]
## type terrains.
## [code]icon[/code] is a [Dictionary] with either a [code]path[/code] string pointing
## to a resource, or a [code]source_id[/code] [int] and a [code]coord[/code] [Vector2i].
func set_terrain(ts: TileSet, index: int, name: String, color: Color, type: int, categories: Array = [], icon: Dictionary = {valid = false}) -> bool:
	if !ts or name.is_empty() or index < 0 or type < 0 or type == TerrainType.DECORATION or type >= TerrainType.MAX:
		return false
	
	var ts_meta := _get_terrain_meta(ts)
	if index >= ts_meta.terrains.size():
		return false
	
	if type == TerrainType.CATEGORY and !categories.is_empty():
		return false
	for c in categories:
		if c < 0 or c == index or c >= ts_meta.terrains.size() or ts_meta.terrains[c][2] != TerrainType.CATEGORY:
			return false
	
	var icon_valid = icon.get("valid", "true")
	if icon_valid:
		match icon:
			{}, {"path"}, {"source_id", "coord"}: pass
			_: return false
	
	if type != TerrainType.CATEGORY:
		for t in ts_meta.terrains:
			t[3].erase(index)
	
	ts_meta.terrains[index] = [name, color, type, categories, icon]
	_set_terrain_meta(ts, ts_meta)
	
	_clear_invalid_peering_types(ts)
	_purge_cache(ts)
	return true


## Swaps the terrains at [code]index1[/code] and [code]index2[/code] in [TileSet].
func swap_terrains(ts: TileSet, index1: int, index2: int) -> bool:
	if !ts or index1 < 0 or index2 < 0 or index1 == index2:
		return false
	
	var ts_meta := _get_terrain_meta(ts)
	if index1 >= ts_meta.terrains.size() or index2 >= ts_meta.terrains.size():
		return false
	
	for t in ts_meta.terrains:
		var has1 = t[3].has(index1)
		var has2 = t[3].has(index2)
		
		if has1 and !has2:
			t[3].erase(index1)
			t[3].push_back(index2)
		elif has2 and !has1:
			t[3].erase(index2)
			t[3].push_back(index1)
	
	for s in ts.get_source_count():
		var source := ts.get_source(ts.get_source_id(s)) as TileSetAtlasSource
		if !source:
			continue
		for t in source.get_tiles_count():
			var coord := source.get_tile_id(t)
			for a in source.get_alternative_tiles_count(coord):
				var alternate := source.get_alternative_tile_id(coord, a)
				var td := source.get_tile_data(coord, alternate)
				
				var td_meta := _get_tile_meta(td)
				if td_meta.type == TileCategory.NON_TERRAIN:
					continue
				
				if td_meta.type == index1:
					td_meta.type = index2
				elif td_meta.type == index2:
					td_meta.type = index1
				
				for peering in td_meta.keys():
					if !(peering is int):
						continue
					
					var fixed_peering = []
					for p in td_meta[peering]:
						if p == index1:
							fixed_peering.append(index2)
						elif p == index2:
							fixed_peering.append(index1)
						else:
							fixed_peering.append(p)
					td_meta[peering] = fixed_peering
				
				_set_tile_meta(ts, td, td_meta)
	
	var temp = ts_meta.terrains[index1]
	ts_meta.terrains[index1] = ts_meta.terrains[index2]
	ts_meta.terrains[index2] = temp
	_set_terrain_meta(ts, ts_meta)
	
	_purge_cache(ts)
	return true


# Terrain tile data

## For a tile in a [TileSet] as specified by [TileData], set the terrain associated
## with that tile to [code]type[/code], which is an index of an existing terrain.
## Returns [code]true[/code] on success.
func set_tile_terrain_type(ts: TileSet, td: TileData, type: int) -> bool:
	if !ts or !td or type < TileCategory.NON_TERRAIN:
		return false
	
	var td_meta = _get_tile_meta(td)
	td_meta.type = type
	if type == TileCategory.NON_TERRAIN:
		td_meta = null
	_set_tile_meta(ts, td, td_meta)
	
	_clear_invalid_peering_types(ts)
	_purge_cache(ts)
	return true


## Returns the terrain type associated with tile specified by [TileData]. Returns
## -1 if the tile has no associated terrain.
func get_tile_terrain_type(td: TileData) -> int:
	if !td:
		return TileCategory.ERROR
	var td_meta := _get_tile_meta(td)
	return td_meta.type


## For a tile represented by [TileData] [code]td[/code] in [TileSet]
## [code]ts[/code], sets [enum SymmetryType] [code]type[/code]. This controls
## how the tile is rotated/mirrored during placement.
func set_tile_symmetry_type(ts: TileSet, td: TileData, type: int) -> bool:
	if !ts or !td or type < SymmetryType.NONE or type > SymmetryType.ALL:
		return false
	
	var td_meta := _get_tile_meta(td)
	if td_meta.type == TileCategory.NON_TERRAIN:
		return false
	
	td_meta.symmetry = type
	_set_tile_meta(ts, td, td_meta)
	_purge_cache(ts)
	return true


## For a tile [code]td[/code], returns the [enum SymmetryType] which that
## tile uses.
func get_tile_symmetry_type(td: TileData) -> int:
	if !td:
		return SymmetryType.NONE
	
	var td_meta := _get_tile_meta(td)
	return td_meta.get("symmetry", SymmetryType.NONE)


## Returns an Array of all [TileData] tiles included in the specified
## terrain [code]type[/code] for the [TileSet] [code]ts[/code]
func get_tiles_in_terrain(ts: TileSet, type: int) -> Array[TileData]:
	var result:Array[TileData] = []
	if !ts or type < TileCategory.EMPTY:
		return result
	
	var cache := _get_cache(ts)
	if type > cache.size():
		return result
	
	var tiles = cache[type]
	if !tiles:
		return result
	for c in tiles:
		if c[0] < 0:
			continue
		var source := ts.get_source(c[0]) as TileSetAtlasSource
		var td := source.get_tile_data(c[1], c[2])
		result.push_back(td)
	
	return result


## Returns an [Array] of [Dictionary] items including information about each 
## tile included in the specified terrain [code]type[/code] for 
## the [TileSet] [code]ts[/code]. Each Dictionary item includes 
## [TileSetAtlasSource] [code]source[/code], [TileData] [code]td[/code], 
## [Vector2i] [code]coord[/code], and [int] [code]alt_id[/code].
func get_tile_sources_in_terrain(ts: TileSet, type: int) -> Array[Dictionary]:
	var result:Array[Dictionary] = []
	
	var cache := _get_cache(ts)
	var tiles = cache[type]
	if !tiles:
		return result
	for c in tiles:
		if c[0] < 0:
			continue
		var source := ts.get_source(c[0]) as TileSetAtlasSource
		if not source:
			continue
		var td := source.get_tile_data(c[1], c[2])
		result.push_back({
			source = source,
			td = td,
			coord = c[1],
			alt_id = c[2]
		})
	
	return result


## For a [TileSet]'s tile, specified by [TileData], add terrain [code]type[/code]
## (an index of a terrain) to match this tile in direction [code]peering[/code],
## which is of type [enum TileSet.CellNeighbor]. Returns [code]true[/code] on success.
func add_tile_peering_type(ts: TileSet, td: TileData, peering: int, type: int) -> bool:
	if !ts or !td or peering < 0 or peering > 15 or type < TileCategory.EMPTY:
		return false
	
	var ts_meta := _get_terrain_meta(ts)
	var td_meta := _get_tile_meta(td)
	if td_meta.type < TileCategory.EMPTY or td_meta.type >= ts_meta.terrains.size():
		return false
	
	if !td_meta.has(peering):
		td_meta[peering] = [type]
	elif !td_meta[peering].has(type):
		td_meta[peering].append(type)
	else:
		return false
	_set_tile_meta(ts, td, td_meta)
	_purge_cache(ts)
	return true


## For a [TileSet]'s tile, specified by [TileData], remove terrain [code]type[/code]
## from matching in direction [code]peering[/code], which is of type [enum TileSet.CellNeighbor].
## Returns [code]true[/code] on success.
func remove_tile_peering_type(ts: TileSet, td: TileData, peering: int, type: int) -> bool:
	if !ts or !td or peering < 0 or peering > 15 or type < TileCategory.EMPTY:
		return false
	
	var td_meta := _get_tile_meta(td)
	if !td_meta.has(peering):
		return false
	if !td_meta[peering].has(type):
		return false
	td_meta[peering].erase(type)
	if td_meta[peering].is_empty():
		td_meta.erase(peering)
	_set_tile_meta(ts, td, td_meta)
	_purge_cache(ts)
	return true


## For the tile specified by [TileData], return an [Array] of peering directions
## for which terrain matching is set up. These will be of type [enum TileSet.CellNeighbor].
func tile_peering_keys(td: TileData) -> Array:
	if !td:
		return []
	
	var td_meta := _get_tile_meta(td)
	var result := []
	for k in td_meta:
		if k is int:
			result.append(k)
	return result


## For the tile specified by [TileData], return the [Array] of terrains that match
## for the direction [code]peering[/code] which should be of type [enum TileSet.CellNeighbor].
func tile_peering_types(td: TileData, peering: int) -> Array:
	if !td or peering < 0 or peering > 15:
		return []
	
	var td_meta := _get_tile_meta(td)
	return td_meta[peering].duplicate() if td_meta.has(peering) else []


## For the tile specified by [TileData], return the [Array] of peering directions
## for the specified terrain type [code]type[/code].
func tile_peering_for_type(td: TileData, type: int) -> Array:
	if !td:
		return []
	
	var td_meta := _get_tile_meta(td)
	var result := []
	var sides := tile_peering_keys(td)
	for side in sides:
		if td_meta[side].has(type):
			result.push_back(side)
	
	result.sort()
	return result


# Painting

## Applies the terrain [code]type[/code] to the [TileMapLayer] for the [Vector2i]
## [code]coord[/code]. Returns [code]true[/code] if it succeeds. Use [method set_cells]
## to change multiple tiles at once.
## [br][br]
## Use terrain type -1 to erase cells.
func set_cell(tm: TileMapLayer, coord: Vector2i, type: int) -> bool:
	if !tm or !tm.tile_set or type < TileCategory.EMPTY:
		return false
	
	if type == TileCategory.EMPTY:
		tm.erase_cell(coord)
		return true
	
	var cache := _get_cache(tm.tile_set)
	if type >= cache.size():
		return false
	
	if cache[type].is_empty():
		return false
	
	var tile = cache[type].front()
	tm.set_cell(coord, tile[0], tile[1], tile[2])
	return true


## Applies the terrain [code]type[/code] to the [TileMapLayer] for the
## [Vector2i] [code]coords[/code]. Returns [code]true[/code] if it succeeds.
## [br][br]
## Note that this does not cause the terrain solver to run, so this will just place
## an arbitrary terrain-associated tile in the given position. To run the solver,
## you must set the require cells, and then call either [method update_terrain_cell],
## [method update_terrain_cels], or [method update_terrain_area].
## [br][br]
## If you want to prepare changes to the tiles in advance, you can use [method create_terrain_changeset]
## and the associated functions.
## [br][br]
## Use terrain type -1 to erase cells.
func set_cells(tm: TileMapLayer, coords: Array, type: int) -> bool:
	if !tm or !tm.tile_set or type < TileCategory.EMPTY:
		return false
	
	if type == TileCategory.EMPTY:
		for c in coords:
			tm.erase_cell(c)
		return true
	
	var cache := _get_cache(tm.tile_set)
	if type >= cache.size():
		return false
	
	if cache[type].is_empty():
		return false
	
	var tile = cache[type].front()
	for c in coords:
		tm.set_cell(c, tile[0], tile[1], tile[2])
	return true


## Replaces an existing tile on the [TileMapLayer] for the [Vector2i]
## [code]coord[/code] with a new tile in the provided terrain [code]type[/code] 
## *only if* there is a tile with a matching set of peering sides in this terrain.
## Returns [code]true[/code] if any tiles were changed. Use [method replace_cells]
## to replace multiple tiles at once.
func replace_cell(tm: TileMapLayer, coord: Vector2i, type: int) -> bool:
	if !tm or !tm.tile_set or type < 0:
		return false
	
	var cache := _get_cache(tm.tile_set)
	if type >= cache.size():
		return false
	
	if cache[type].is_empty():
		return false
	
	var td = tm.get_cell_tile_data(coord)
	if !td:
		return false
	
	var ts_meta := _get_terrain_meta(tm.tile_set)
	var categories = ts_meta.terrains[type][3]
	var check_types = [type] + categories
	
	for check_type in check_types:
		var placed_peering = tile_peering_for_type(td, check_type)
		for pt in get_tiles_in_terrain(tm.tile_set, type):
			var check_peering := tile_peering_for_type(pt, check_type)
			if placed_peering == check_peering:
				var tile = cache[type].front()
				tm.set_cell(coord, tile[0], tile[1], tile[2])
				return true
	
	return false


## Replaces existing tiles on the [TileMapLayer] for the [Vector2i]
## [code]coords[/code] with new tiles in the provided terrain [code]type[/code] 
## *only if* there is a tile with a matching set of peering sides in this terrain
## for each tile.
## Returns [code]true[/code] if any tiles were changed.
func replace_cells(tm: TileMapLayer, coords: Array, type: int) -> bool:
	if !tm or !tm.tile_set or type < 0:
		return false
	
	var cache := _get_cache(tm.tile_set)
	if type >= cache.size():
		return false
	
	if cache[type].is_empty():
		return false
	
	var ts_meta := _get_terrain_meta(tm.tile_set)
	var categories = ts_meta.terrains[type][3]
	var check_types = [type] + categories
	
	var changed = false
	var potential_tiles = get_tiles_in_terrain(tm.tile_set, type)
	for c in coords:
		var found = false
		var td = tm.get_cell_tile_data(c)
		if !td:
			continue
		for check_type in check_types:
			var placed_peering = tile_peering_for_type(td, check_type)
			for pt in potential_tiles:
				var check_peering = tile_peering_for_type(pt, check_type)
				if placed_peering == check_peering:
					var tile = cache[type].front()
					tm.set_cell(c, tile[0], tile[1], tile[2])
					changed = true
					found = true
					break
			
			if found:
				break
	
	return changed


## Returns the terrain type detected in the [TileMapLayer] at specified [Vector2i]
## [code]coord[/code]. Returns -1 if tile is not valid or does not contain a
## tile associated with a terrain.
func get_cell(tm: TileMapLayer, coord: Vector2i) -> int:
	if !tm or !tm.tile_set:
		return TileCategory.ERROR
	
	if tm.get_cell_source_id(coord) == -1:
		return TileCategory.EMPTY
	
	var t := tm.get_cell_tile_data(coord)
	if !t:
		return TileCategory.NON_TERRAIN
	
	return _get_tile_meta(t).type


## Runs the tile solving algorithm on the [TileMapLayer] for the given
## [Vector2i] coordinates in the [code]cells[/code] parameter. By default,
## the surrounding cells are also solved, but this can be adjusted by passing [code]false[/code]
## to the [code]and_surrounding_cells[/code] parameter.
## [br][br]
## See also [method update_terrain_area] and [method update_terrain_cell].
func update_terrain_cells(tm: TileMapLayer, cells: Array, and_surrounding_cells := true) -> void:
	if !tm or !tm.tile_set:
		return
	
	if and_surrounding_cells:
		cells = _widen(tm, cells)
	var needed_cells := _widen(tm, cells)
	
	var types := {}
	for c in needed_cells:
		types[c] = get_cell(tm, c)
	
	var ts_meta := _get_terrain_meta(tm.tile_set)
	var cache := _get_cache(tm.tile_set)
	for c in cells:
		_update_tile_immediate(tm, c, ts_meta, types, cache)


## Runs the tile solving algorithm on the [TileMapLayer] for the given [Vector2i]
## [code]cell[/code]. By default, the surrounding cells are also solved, but
## this can be adjusted by passing [code]false[/code] to the [code]and_surrounding_cells[/code]
## parameter. This calls through to [method update_terrain_cells].
func update_terrain_cell(tm: TileMapLayer, cell: Vector2i, and_surrounding_cells := true) -> void:
	update_terrain_cells(tm, [cell], and_surrounding_cells)


## Runs the tile solving algorithm on the [TileMapLayer] for the given [Rect2i]
## [code]area[/code]. By default, the surrounding cells are also solved, but
## this can be adjusted by passing [code]false[/code] to the [code]and_surrounding_cells[/code]
## parameter.
## [br][br]
## See also [method update_terrain_cells].
func update_terrain_area(tm: TileMapLayer, area: Rect2i, and_surrounding_cells := true) -> void:
	if !tm or !tm.tile_set:
		return
	
	# Normalize area and extend so tiles cover inclusive space
	area = area.abs()
	area.size += Vector2i.ONE
	
	var edges = []
	for x in range(area.position.x, area.end.x):
		edges.append(Vector2i(x, area.position.y))
		edges.append(Vector2i(x, area.end.y - 1))
	for y in range(area.position.y + 1, area.end.y - 1):
		edges.append(Vector2i(area.position.x, y))
		edges.append(Vector2i(area.end.x - 1, y))
	
	var additional_cells := []
	var needed_cells := _widen_with_exclusion(tm, edges, area)
	
	if and_surrounding_cells:
		additional_cells = needed_cells
		needed_cells = _widen_with_exclusion(tm, needed_cells, area)
	
	var types := {}
	for y in range(area.position.y, area.end.y):
		for x in range(area.position.x, area.end.x):
			var coord = Vector2i(x, y)
			types[coord] = get_cell(tm, coord)
	for c in needed_cells:
		types[c] = get_cell(tm, c)
	
	var ts_meta := _get_terrain_meta(tm.tile_set)
	var cache := _get_cache(tm.tile_set)
	for y in range(area.position.y, area.end.y):
		for x in range(area.position.x, area.end.x):
			var coord := Vector2i(x, y)
			_update_tile_immediate(tm, coord, ts_meta, types, cache)
	for c in additional_cells:
		_update_tile_immediate(tm, c, ts_meta, types, cache)


## For a [TileMapLayer], create a changeset that will
## be calculated via a [WorkerThreadPool], so it will not delay processing the current
## frame or affect the framerate.
## [br][br]
## The [code]paint[/code] parameter must be a [Dictionary] with keys of type [Vector2i]
## representing map coordinates, and integer values representing terrain types.
## [br][br]
## Returns a [Dictionary] with internal details. See also [method is_terrain_changeset_ready],
## [method apply_terrain_changeset], and [method wait_for_terrain_changeset].
func create_terrain_changeset(tm: TileMapLayer, paint: Dictionary) -> Dictionary:
	# Force cache rebuild if required
	var _cache := _get_cache(tm.tile_set)
	
	var cells := paint.keys()
	var needed_cells := _widen(tm, cells)
	
	var types := {}
	for c in needed_cells:
		types[c] = paint[c] if paint.has(c) else get_cell(tm, c)
	
	var placements := []
	placements.resize(cells.size())
	
	var ts_meta := _get_terrain_meta(tm.tile_set)
	var work := func(n: int):
		placements[n] = _update_tile_deferred(tm, cells[n], ts_meta, types, _cache)
	
	return {
		"valid": true,
		"tilemap": tm,
		"cells": cells,
		"placements": placements,
		"group_id": WorkerThreadPool.add_group_task(work, cells.size(), -1, false, "BetterTerrain")
	}


## Returns [code]true[/code] if a changeset created by [method create_terrain_changeset]
## has finished the threaded calculation and is ready to be applied by [method apply_terrain_changeset].
## See also [method wait_for_terrain_changeset].
func is_terrain_changeset_ready(change: Dictionary) -> bool:
	if !change.has("group_id"):
		return false
	
	return WorkerThreadPool.is_group_task_completed(change.group_id)


## Blocks until a changeset created by [method create_terrain_changeset] finishes.
## This is useful to tidy up threaded work in the event that a node is to be removed
## whilst still waiting on threads.
## [br][br]
## Usage example:
## [codeblock]
## func _exit_tree():
##     if changeset.valid:
##         BetterTerrain.wait_for_terrain_changeset(changeset)
## [/codeblock]
func wait_for_terrain_changeset(change: Dictionary) -> void:
	if change.has("group_id"):
		WorkerThreadPool.wait_for_group_task_completion(change.group_id)


## Apply the changes in a changeset created by [method create_terrain_changeset]
## once it is confirmed by [method is_terrain_changeset_ready]. The changes will
## be applied to the [TileMapLayer] that the changeset was initialized with.
## [br][br]
## Completed changesets can be applied multiple times, and stored for as long as
## needed once calculated.
func apply_terrain_changeset(change: Dictionary) -> void:
	for n in change.cells.size():
		var placement = change.placements[n]
		if placement:
			change.tilemap.set_cell(change.cells[n], placement[0], placement[1], placement[2])
