class_name GunkBody extends StaticBody3D
## StaticBody3D with an associated "gunkable" mesh.

## Emitted from the main thread after the clear total is asynchronously updated.
signal clear_total_updated(clear_total: float)

const CONTINUITY_LIMIT := 32
const BUFFER_LIMIT := 3
const FACE_EPSILON := 0.01
const CLEAR_COLOR := Color.BLACK
const MASK_COLOR := Color.RED
const CLEAR_TOTAL_EPSILON := 1
const MASK_THRESHOLD := 0.5

@export var mask_dim := 256
@export var mesh_instance: MeshInstance3D
@export var initial_mask: Texture

@export var source_gunk_material: ShaderMaterial

var meshtool := MeshDataTool.new()

var _polyline_buffer: Array[Vector2] = []
var _continued_paint_this_frame := false

var _multiline_buffer := PackedVector2Array()
var _multiline_width := 1.0

var _clear_total := 0.0
var _prev_clear_total := -1.0

# _clear_total is async computed in separate thread
var _mask_tx: Texture2D
var _cached_mask: Image
var _thread: Thread
var _mutex: Mutex = Mutex.new()
var _semaphore: Semaphore = Semaphore.new()
var _exit_thread := false

@onready var mask_viewport: SubViewport = %MaskViewport
@onready var mask_control: DrawController = %MaskControl
@onready var mask_texture: TextureRect = %MaskTexture

@onready var debug_draw: DebugDraw = $DebugDraw


func _ready() -> void:
	# If no mesh_instance was provided, use the parent.
	if not mesh_instance:
		var parent := get_parent() as MeshInstance3D
		if parent:
			mesh_instance = parent
	assert(mesh_instance, "GunkBody %s has no associated mesh!" % str(self))

	mask_viewport.size = Vector2(mask_dim, mask_dim)

	# Create a new instance of the gunk material
	var mat_instance: ShaderMaterial = source_gunk_material.duplicate()
	# Set material gunk mask to our mask viewport texture
	mat_instance.set_shader_parameter("gunk_mask", mask_viewport.get_texture())
	# Overlay mesh with gunk material
	mesh_instance.material_overlay = mat_instance

	if initial_mask:
		mask_texture.texture = initial_mask

	# Initialize meshtool
	meshtool.create_from_surface(mesh_instance.mesh as ArrayMesh, 0)

	_thread = Thread.new()
	_thread.start(_async_compute_clear_total)


func _trigger_recompute_deferred() -> void:
	_mutex.lock()
	_mask_tx = mask_viewport.get_texture()
	#_mask_img = mask_viewport.get_texture().get_image()
	_mutex.unlock()
	_semaphore.post()


func _async_compute_clear_total() -> void:
	# Ignore first two calls (initial mask clear & texture)
	# NOTE: this technically could put us in a glitched state if that mask clear & texture don't happen
	# and the thread wouldn't be able to hit the exit condition.
	# If the application hangs in a weird way immediately after loading a scene, maybe this is why!
	_semaphore.wait()
	_semaphore.wait()
	while true:
		_semaphore.wait()

		# check exit flag
		_mutex.lock()
		var exiting := _exit_thread
		_mutex.unlock()

		if exiting:
			break

		# Get mask from GPU
		# TODO: does this need mutex protection?
		_mutex.lock()
		_cached_mask = _mask_tx.get_image()
		_cached_mask.convert(Image.FORMAT_R8)
		_mutex.unlock()
		var px_data := _cached_mask.get_data()
		var px_sum := 0
		for px in px_data:
			px_sum += px
		var new_total := px_sum / 255.0

		# Write total
		_mutex.lock()
		_clear_total = new_total
		_mutex.unlock()


func _exit_tree() -> void:
	# Flag processing flag to cleanly exit
	_mutex.lock()
	_exit_thread = true
	_mutex.unlock()
	_semaphore.post()
	_thread.wait_to_finish()


func clear_all() -> void:
	mask_control.clear(MASK_COLOR)


## Get the precise number of gunk pixels cleared from this image.
##
## This will use a cached result unless the mask has been painted since the last calculation.
## Be aware that cache misses are potentially quite expensive.
func get_clear_total() -> float:
	# Protect with mutex
	_mutex.lock()
	var total := _clear_total
	_mutex.unlock()

	return total


## Get the index of the mesh face on which the given point+normal lies.
##
## Returns -1 if the given point+normal does not lie on a mesh face within tolerance.
func _get_face(point: Vector3, normal: Vector3) -> int:
	var min_i := -1
	var min_planar_dist := INF

	for i in range(meshtool.get_face_count()):
		var sdist := meshtool.get_face_normal(i).distance_squared_to(normal)
		if sdist > FACE_EPSILON:
			continue
		# Normals match, so check if the point is on this face
		var v1 := meshtool.get_vertex(meshtool.get_face_vertex(i, 0))
		var v2 := meshtool.get_vertex(meshtool.get_face_vertex(i, 1))
		var v3 := meshtool.get_vertex(meshtool.get_face_vertex(i, 2))
		if GeometryTools.is_in_triangle(point, v1, v2, v3):
			var planar_dist := absf(Plane(v1, v2, v3).distance_to(point))
			if planar_dist < min_planar_dist:
				min_i = i
				min_planar_dist = planar_dist
	return min_i


## UV-unwrap a point+normal in 3D space onto the UV space of this mesh.
##
## Returns Vector2.INF if the given point+normal does not lie on this mesh within tolerance.
func _get_uv(point: Vector3, normal: Vector3) -> Vector2:
	var face := _get_face(point, normal)
	if face < 0:
		return Vector2.INF
	var fv0 := meshtool.get_face_vertex(face, 0)
	var fv1 := meshtool.get_face_vertex(face, 1)
	var fv2 := meshtool.get_face_vertex(face, 2)
	var v1 := meshtool.get_vertex(fv0)
	var v2 := meshtool.get_vertex(fv1)
	var v3 := meshtool.get_vertex(fv2)
	var bc := GeometryTools.barycentric(point, v1, v2, v3)  # TODO memoize
	var uv1 := meshtool.get_vertex_uv(fv0)
	var uv2 := meshtool.get_vertex_uv(fv1)
	var uv3 := meshtool.get_vertex_uv(fv2)
	return (uv1 * bc.x) + (uv2 * bc.y) + (uv3 * bc.z)


## UV-unwrap a point+normal in 3D space onto the pixel space of this mesh's texture.
##
## Returns Vector2.INF if the given point+normal does not lie on this mesh within tolerance.
func _get_px(point: Vector3, normal: Vector3) -> Vector2:
	debug_draw.draw_vector(normal, point)

	return _get_uv(point * global_transform, normal * global_basis) * mask_control.size


func is_point_gunked(point: Vector3, normal: Vector3) -> bool:
	var px := _get_px(point, normal)
	var result := false
	# XXX deadlock potential?
	_mutex.lock()
	result = _cached_mask and _cached_mask.get_pixelv(px).r < MASK_THRESHOLD
	_mutex.unlock()

	return result


## Paint a dot on the gunk mask.
func paint_dot(point: Vector3, normal: Vector3, radius: float, color: Color = MASK_COLOR) -> void:
	var px := _get_px(point, normal)
	if px == Vector2.INF:
		return

	mask_control.queue_draw(
		func() -> void: mask_control.draw_circle(px, radius, color, true, -1, true)
	)


## Paint a continuous line on the gunk mask if called on successive frames.
func paint_continuous(
	point: Vector3, normal: Vector3, width: float, color: Color = MASK_COLOR
) -> void:
	var px := _get_px(point, normal)
	if _polyline_buffer and px.distance_to(_polyline_buffer[0]) <= CONTINUITY_LIMIT:
		_polyline_buffer.push_front(px)
		if len(_polyline_buffer) > BUFFER_LIMIT:
			_polyline_buffer.pop_back()
		var polyline := PackedVector2Array(_polyline_buffer)
		mask_control.queue_draw(
			func() -> void: mask_control.draw_polyline(polyline, color, width * 2, true)
		)
	else:
		_polyline_buffer = [px]

	# Always paint a circle, to round out corners & cap ends
	mask_control.queue_draw(
		func() -> void: mask_control.draw_circle(px, width, color, true, -1, true)
	)
	_continued_paint_this_frame = true


## Add a segment to the multiline to paint this frame.
func add_to_multiline(
	point_a: Vector3, normal_a: Vector3, point_b: Vector3, normal_b: Vector3, width: float
) -> void:
	var px_a := _get_px(point_a, normal_a)
	var px_b := _get_px(point_b, normal_b)
	if px_a == Vector2.INF or px_b == Vector2.INF or px_a.distance_to(px_b) > CONTINUITY_LIMIT:
		return
	_multiline_buffer.append(px_a)
	_multiline_buffer.append(px_b)
	_multiline_width = width


func _process(_delta: float) -> void:
	# Check if processing thread has updated the clear total
	_mutex.lock()
	var new_total := _clear_total
	_mutex.unlock()
	var delta := new_total - _prev_clear_total
	if abs(delta) > CLEAR_TOTAL_EPSILON:
		# Do not fire signal on first compute after initialization
		# This prevents the player from collecting the grunk from the initial mask.
		if _prev_clear_total >= 0:
			clear_total_updated.emit(new_total)
			# XXX due to fp error, this will drift from the "true count" over time
			# but it probably won't matter :shrug:
			Game.manager.collect_grunk(delta)
		_prev_clear_total = new_total

	# If paint_continuous wasn't called last frame, stop the current polyline.
	if not _continued_paint_this_frame:
		_polyline_buffer = []
	_continued_paint_this_frame = false

	# If we've buffered a multiline in the last frame, draw it & clear the buffer.
	# The width of the multiline will just be the width of the last call.
	if _multiline_buffer:
		var points := _multiline_buffer.duplicate()
		var width := _multiline_width
		mask_control.queue_draw(
			func() -> void: mask_control.draw_multiline(points, MASK_COLOR, width, true)
		)
		_multiline_buffer = PackedVector2Array()


func _on_mask_painted() -> void:
	# XXX any problem with posting each frame?
	_trigger_recompute_deferred.call_deferred()
