@tool
extends Node3D
class_name LightmapProbeGrid

signal size_changed
signal probes_changed

const max_probes: int = 1000

## Only selected layers will be seen by LightmapProbeGrid. Works like Camera3D Cull Mask.[br][br]
## NOTE: NOT compatible with LightmapProbeGrid v1.0
@export_flags_3d_render var visual_cull_mask: int = 1048575

@export var size: Vector3 = Vector3.ONE:
	set(value):
		# size cannot be zero or negative
		size = value.clamp(Vector3(1E-6, 1E-6, 1E-6), Vector3.INF)
		size_changed.emit()
	get:
		return size

@onready var depth_shader: Shader = preload("Depth.gdshader")

var probes_number: Vector3i = Vector3i(2, 2, 2):
	set(value):
		probes_number = value
		set_probes_number()
	get:
		return probes_number

var planned_probes: int = 8
var current_probes: int = 8

var old_size: Vector3 = Vector3.ONE
var old_scale: Vector3 = Vector3.ONE
var warned: bool = false

var far_distance: float = 1
var object_size: float = 1

var gizmo_lines: PackedVector3Array = []


func _get_property_list() -> Array[Dictionary]:
	var properties: Array[Dictionary] = []

	properties.append({
		"name": "probes_number",
		"type": TYPE_VECTOR3I,
		"usage": PROPERTY_USAGE_STORAGE
	})

	properties.append({
		"name": "far_distance",
		"type": TYPE_FLOAT,
		"usage": PROPERTY_USAGE_STORAGE
	})

	properties.append({
		"name": "object_size",
		"type": TYPE_FLOAT,
		"usage": PROPERTY_USAGE_STORAGE
	})

	return properties


func _enter_tree() -> void:
	if get_child_count() < 1:
		generate_probes()


func _ready() -> void:
	size_changed.connect(scale_probes)
	set_notify_local_transform(true)
	old_size = size
	old_scale = scale
	current_probes = get_child_count()


# Keep local scale fixed. Reflect in "size" if the user try to scale
func _notification(what: int) -> void:
	if (what == NOTIFICATION_LOCAL_TRANSFORM_CHANGED) and not scale.is_equal_approx(Vector3.ONE):
		if not warned:
			printerr("LightmapProbeGrid: Resetting Scale, please use the handles (red dots) or ",
					"the property \"Size\" in LightmapProbeGridsection")
			warned = true

		if(scale.x <= 0):
			scale = Vector3.ONE
			return

		# TODO take a look on this workaround
		var scale_diff: Vector3 = abs(scale - Vector3.ONE)
		var size_sign: Vector3 = sign(scale - old_scale)
		size += size_sign * scale_diff / 10.0

		old_scale = scale
		scale = Vector3.ONE


func _get_configuration_warnings() -> PackedStringArray:
	var warnings: PackedStringArray = []

	if planned_probes > max_probes:
		var text: String = "LightmapProbeGrid: The maximum number of Probes must be " + \
				str(max_probes) + ". Please consider add more instances of LightmapProbeGrid"
		warnings.append(text)
		printerr(text)

	# Returning an empty array means "no warning".
	return warnings


func set_probes_number() -> void:
	planned_probes = probes_number.x * probes_number.y * probes_number.z
	update_configuration_warnings()
	probes_changed.emit()


func scale_probes() -> void:
	var new_size: Vector3 = size / old_size

	# Scaling all probes
	for probe: Node3D in get_children():
		probe.position *= new_size

	old_size = size


func generate_probes() -> void:
	# check number of probes
	if planned_probes > max_probes:
		return

	# Clear all previews probes
	for i: int in get_child_count():
		get_child(i).queue_free()
		# Wait for the last one to be cleaned
		if i == get_child_count() -1:
			await get_child(i).tree_exited

	# Defining probe arrays
	var probes_positions: Array[Vector3] = []
	var probes_names: Array[String] = []

	var probes_x: float = probes_number.x
	var probes_y: float = probes_number.y
	var probes_z: float = probes_number.z

	# Distance between probes
	var step_x: float = size.x / (probes_x - 1)
	var step_y: float = size.y / (probes_y - 1)
	var step_z: float = size.z / (probes_z - 1)

	# Starting relative positions
	var start_position: Vector3 = Vector3.ONE * size / 2.0
	var current_position: Vector3 = Vector3.ZERO

	# Defining Probes relative positions and names
	for x: float in probes_x:
		for y: float in probes_y:
			for z: float in probes_z:
				current_position.x = start_position.x - step_x * x
				current_position.y = start_position.y - step_y * y
				current_position.z = start_position.z - step_z * z
				probes_positions.append(current_position)
				probes_names.append("LightmapProbe %.f, %.f, %.f" % [x, y, z])

	# Generating probes
	var root_node: Node = get_tree().edited_scene_root

	for i: int in range(probes_positions.size()):
		var probe: LightmapProbe = LightmapProbe.new()
		probe.position = probes_positions[i]
		probe.name = probes_names[i]
		add_child(probe)
		probe.set_owner(root_node)

	current_probes = probes_number.x * probes_number.y * probes_number.z
	set_probes_number()


# Workaround to raycast without colliders. Consists in a camera with a filter in front that shows
# the depth texture. The camera.far is the "ray" lenght and camera rotation is the "ray" orientation
# https://docs.godotengine.org/en/stable/tutorials/shaders/advanced_postprocessing.html#depth-texture
func add_GPU_raycaster(probe: Node3D) -> void:
	var root_node: Node = get_tree().edited_scene_root

	# SubViewport that will host the camera
	var sub_viewport: SubViewport = SubViewport.new()
	sub_viewport.name = "GPUraycast"
	sub_viewport.size = Vector2(2, 2)
	sub_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
	sub_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_NEVER
	sub_viewport.handle_input_locally = false
	sub_viewport.debug_draw = Viewport.DEBUG_DRAW_UNSHADED

	sub_viewport.positional_shadow_atlas_size = 0
	sub_viewport.positional_shadow_atlas_quad_0 = Viewport.SHADOW_ATLAS_QUADRANT_SUBDIV_DISABLED
	sub_viewport.positional_shadow_atlas_quad_1 = Viewport.SHADOW_ATLAS_QUADRANT_SUBDIV_DISABLED
	sub_viewport.positional_shadow_atlas_quad_2 = Viewport.SHADOW_ATLAS_QUADRANT_SUBDIV_DISABLED
	sub_viewport.positional_shadow_atlas_quad_3 = Viewport.SHADOW_ATLAS_QUADRANT_SUBDIV_DISABLED

	probe.add_child(sub_viewport)
	sub_viewport.set_owner(root_node)

	# Camera for the viewport
	var camera_3d: Camera3D = Camera3D.new()
	camera_3d.projection = Camera3D.PROJECTION_ORTHOGONAL
	camera_3d.size = 0.001
	camera_3d.near = 0.001
	camera_3d.far = 1.0
	sub_viewport.add_child(camera_3d)
	camera_3d.set_owner(root_node)
	camera_3d.position = probe.global_position
	camera_3d.rotation = Vector3.ZERO
	camera_3d.cull_mask = visual_cull_mask

	# Depth filter: A quad with a material that shows the Depth texture. This goes in front of the
	# camera
	var depth_material: ShaderMaterial = ShaderMaterial.new()
	depth_material.shader = depth_shader

	var depth_filter: MeshInstance3D = MeshInstance3D.new()
	var depth_mesh: QuadMesh = QuadMesh.new()
	depth_filter.mesh = depth_mesh
	depth_mesh.material = depth_material
	depth_mesh.size = Vector2.ONE * 0.001
	camera_3d.add_child(depth_filter)
	depth_filter.set_owner(root_node)
	depth_filter.position = Vector3(0, 0, -0.002)
	depth_filter.rotation = Vector3.ZERO


func generate_probes_raycasters(distance: float) -> void:
	for probe in get_children():
		add_GPU_raycaster(probe)


func remove_probes_raycasters() -> void:
	for probe in get_children():
		for child in probe.get_children():
			child.queue_free();


# The function look_at not always work. Exceptions are handled here
func rotate_camera(camera: Camera3D, to: Vector3) -> void:
	var from: Vector3 = camera.position
	# look_at don't work if the node and target have the same position. You cannot look at yourself
	if from == to:
		return

	# look_at don't work if the direction and rotation axix have same orientation. In this case,
	# change the rotation axis
	var direction: Vector3 = abs(to - from)
	var mag = (direction.normalized() - Vector3.UP).length()
	if mag > 0.001:
		camera.look_at(to)
	else:
		camera.look_at(to, Vector3.RIGHT)


# Shoot rays from the center to all the probes. If any object is detected so the probe is
# obstructed and will be cut
func cut_obstructed() -> void:
	await generate_probes_raycasters(far_distance)

	var probes_array: Array[LightmapProbe] = []
	var camera_array: Array[Camera3D] = []
	var subViewport_array: Array[SubViewport] = []
	var results_array: Array[float] = []

	# Populating arrays
	for probe: LightmapProbe in get_children():
		probes_array.append(probe)
		gizmo_lines.append_array([Vector3.ZERO, probe.position])
		var sub_viewport: SubViewport = probe.get_child(0)
		subViewport_array.append(sub_viewport)
		var camera: Camera3D = sub_viewport.get_child(0)
		camera_array.append(camera)

	# Rotating cameras and updating sub_viewports
	for i in range(camera_array.size()):
		var camera: Camera3D = camera_array[i]
		var probe_pos: Vector3 = probes_array[i].global_position
		var sub_viewport: SubViewport = subViewport_array[i]

		camera.position = position
		# The lenght of the "Ray"
		camera.far = (probe_pos - position).length()
		# The direction of the "Ray"
		rotate_camera(camera, probe_pos)
		sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE

	# Getting the values
	await  RenderingServer.frame_post_draw
	for i in range(subViewport_array.size()):
		var sub_viewport = subViewport_array[i]
		var texture: Image = sub_viewport.get_texture().get_image()
		var color: Color = texture.get_pixel(0,0)
		var colorValue: float = color.r
		var result: float = colorValue
		results_array.append(result)

	# Cutting probes
	for i in range(subViewport_array.size()):
		var result: float = results_array[i]
		if result < 1.0:
			var probe = probes_array[i]
			probe.queue_free()
			current_probes -= 1

	set_probes_number()
	remove_probes_raycasters()


# Detect if the probe is far from any object. It will shoot rays on all 6 axis and 8 quadrants.
# If there aren't any objects the probe will be cut
func cut_far() -> void:
	await generate_probes_raycasters(far_distance)

	# 6 axis and 8 quadrants
	var directions: Array[Vector3] = [
		# 6 Axis
			Vector3(0, 0, 1), Vector3(0, 1, 0), Vector3(1, 0, 0),
			Vector3(0, 0, -1), Vector3(0, -1, 0), Vector3(-1, 0, 0),
		# 8 Quadrants
			Vector3(1, 1, 1).normalized(), Vector3(1, 1, -1).normalized(),
			Vector3(1, -1, 1).normalized(),  Vector3(1, -1, -1).normalized(),
			Vector3(-1, 1, 1).normalized(), Vector3(-1, 1, -1).normalized(),
			Vector3(-1, -1, 1).normalized(),  Vector3(-1, -1, -1).normalized()
	]

	var probes_array: Array[LightmapProbe] = []
	var camera_array: Array[Camera3D] = []
	var subViewport_array: Array[SubViewport] = []
	var collisions_number: Array[int] = []

	# Populating arrays
	for probe: LightmapProbe in get_children():
		probes_array.append(probe)
		var sub_viewport: SubViewport = probe.get_child(0)
		subViewport_array.append(sub_viewport)
		var camera: Camera3D = sub_viewport.get_child(0)
		camera_array.append(camera)
	collisions_number.resize(camera_array.size())
	collisions_number.fill(0)

	# Getting data for all cameras on each direction
	for dir in directions:
		# Rotating all cameras to the same direction, and updating viewport
		for i in camera_array.size():
			var probe: LightmapProbe = probes_array[i]
			var sub_viewport: SubViewport = subViewport_array[i]
			var camera: Camera3D = camera_array[i]

			camera.position = probe.global_position
			# The lenght of the "Ray"
			camera.far = far_distance
			# The direction of the "Ray"
			rotate_camera(camera, probe.global_position + dir)
			sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE

			gizmo_lines.append_array([probe.position, probe.position + dir * far_distance])

		# Getting all values for the current direction
		await  RenderingServer.frame_post_draw
		for i in range(subViewport_array.size()):
			var sub_viewport = subViewport_array[i]
			var texture: Image = sub_viewport.get_texture().get_image()
			var color: Color = texture.get_pixel(0,0)
			var colorValue: float = color.r
			var result: float = colorValue
			if result < 1.0:
				collisions_number[i] += 1

	# Cut probes if there are no collisions
	for i in probes_array.size():
		if collisions_number[i] < 1:
			var probe = probes_array[i]
			probe.queue_free()
			current_probes -= 1

	set_probes_number()
	remove_probes_raycasters()


# Detect if probe is inside an object. It will shoot rays from all 6 axis to the probe. If at least
# 4 are obstructed, the probe will be cut
func cut_inside() -> void:
	await generate_probes_raycasters(far_distance)

	# 6 Axis
	var axis: Array[Vector3] = [
			Vector3(0, 0, 1), Vector3(0, 1, 0), Vector3(1, 0, 0),
			Vector3(0, 0, -1), Vector3(0, -1, 0), Vector3(-1, 0, 0),
	]

	var probes_array: Array[LightmapProbe] = []
	var camera_array: Array[Camera3D] = []
	var subViewport_array: Array[SubViewport] = []
	var collisions_number: Array[int] = []

	# Populating arrays
	for probe: LightmapProbe in get_children():
		probes_array.append(probe)
		var sub_viewport: SubViewport = probe.get_child(0)
		subViewport_array.append(sub_viewport)
		var camera: Camera3D = sub_viewport.get_child(0)
		camera_array.append(camera)
	collisions_number.resize(camera_array.size())
	collisions_number.fill(0)

	# Getting data for all cameras on each axis
	for dir in axis:
		# For each direction, position all cameras to look from outside
		# to each probe in object_size distance
		for i in camera_array.size():
			var probe: LightmapProbe = probes_array[i]
			var sub_viewport: SubViewport = subViewport_array[i]
			var camera: Camera3D = camera_array[i]

			camera.position = probe.global_position + dir * object_size
			# The lenght of the "Ray"
			camera.far = object_size
			# The direction of the "Ray"
			rotate_camera(camera, probe.global_position)
			sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE

			gizmo_lines.append_array([probe.position, probe.position + dir * object_size])

		# Getting all values for the current direction
		await  RenderingServer.frame_post_draw
		for i in range(subViewport_array.size()):
			var sub_viewport = subViewport_array[i]
			var texture: Image = sub_viewport.get_texture().get_image()
			var color: Color = texture.get_pixel(0,0)
			var colorValue: float = color.r
			var result: float = colorValue
			if result < 1.0:
				collisions_number[i] += 1

	# Cut probes if there are more than 4 collisions
	for i in probes_array.size():
		if collisions_number[i] > 3:
			var probe = probes_array[i]
			probe.queue_free()
			current_probes -= 1

	set_probes_number()
	remove_probes_raycasters()

