@tool
extends RefCounted

# A domain is the complete area where transforms can (and can't) be placed.
# A Scatter node has one single domain, a domain has one or more shape nodes.
#
# It's the combination of every shape defined under a Scatter node, grouped in
# a single class that exposes utility functions (check if a point is inside, or
# along the surface etc).
#
# An instance of this class is passed to the modifiers during a rebuild.


const BaseShape := preload("../shapes/base_shape.gd")
const Bounds := preload("../common/bounds.gd")


class DomainShapeInfo:
	var node: Node3D
	var shape: BaseShape

	func is_point_inside(point: Vector3, local: bool) -> bool:
		var t: Transform3D
		if is_instance_valid(node):
			t = node.get_transform() if local else node.get_global_transform()
			return shape.is_point_inside(point, t)
		else:
			return false	
	
	func get_corners_global() -> Array:
		return shape.get_corners_global(node.get_global_transform())

# A polygon made of one outer boundary and one or multiple holes (inner polygons)
class ComplexPolygon:
	var inner: Array[PackedVector2Array] = []
	var outer: PackedVector2Array

	func add(polygon: PackedVector2Array) -> void:
		if polygon.is_empty(): return
		if Geometry2D.is_polygon_clockwise(polygon):
			inner.push_back(polygon)
		else:
			if not outer.is_empty():
				print_debug("ProtonScatter error: Replacing polygon's existing outer boundary. This should not happen, please report.")
			outer = polygon

	func add_array(array: Array, reverse := false) -> void:
		for p in array:
			if reverse:
				p.reverse()
			add(p)

	func get_all() -> Array[PackedVector2Array]:
		var res = inner.duplicate()
		res.push_back(outer)
		return res

	func _to_string() -> String:
		var res =  "o: " + var_to_str(outer.size()) + ", i: ["
		for i in inner:
			res += var_to_str(i.size()) + ", "
		res += "]"
		return res


var root: ProtonScatter
var positive_shapes: Array[DomainShapeInfo]
var negative_shapes: Array[DomainShapeInfo]
var bounds_global: Bounds = Bounds.new()
var bounds_local: Bounds = Bounds.new()
var edges: Array[Curve3D] = []


func is_empty() -> bool:
	return positive_shapes.is_empty()


# If a point is in an exclusion shape, returns false
# If a point is in an inclusion shape (but not in an exclusion one), returns true
# If a point is in neither, returns false
func is_point_inside(point: Vector3, local := true) -> bool:
	for s in negative_shapes:
		if s.is_point_inside(point, local):
			return false

	for s in positive_shapes:
		if s.is_point_inside(point, local):
			return true

	return false


# If a point is inside an exclusion shape, returns true
# Returns false in every other case
func is_point_excluded(point: Vector3, local := true) -> bool:
	for s in negative_shapes:
		if s.is_point_inside(point, local):
			return true

	return false


# Recursively find all ScatterShape nodes under the provided root. In case of
# nested Scatter nodes, shapes under these other Scatter nodes will be ignored
func discover_shapes(root_node: Node3D) -> void:
	root = root_node
	positive_shapes.clear()
	negative_shapes.clear()

	if not is_instance_valid(root):
		return

	for c in root.get_children():
		_discover_shapes_recursive(c)
	compute_bounds()
	compute_edges()


func compute_bounds() -> void:
	bounds_global.clear()
	bounds_local.clear()

	if not is_instance_valid(root):
		return

	var gt: Transform3D = root.get_global_transform().affine_inverse()

	for info in positive_shapes:
		for point in info.get_corners_global():
			bounds_global.feed(point)
			bounds_local.feed(gt * point)

	bounds_global.compute_bounds()
	bounds_local.compute_bounds()


func compute_edges() -> void:
	edges.clear()

	if not is_instance_valid(root):
		return

	var source_polygons: Array[ComplexPolygon] = []

	## Retrieve all polygons
	for info in positive_shapes:
		# Store all closed polygons in a specific array
		var polygon := ComplexPolygon.new()
		polygon.add_array(info.shape.get_closed_edges(info.node.transform))

		# Polygons with holes must be merged together first
		if not polygon.inner.is_empty():
			source_polygons.push_back(polygon)
		else:
			source_polygons.push_front(polygon)

		# Store open edges directly since they are already Curve3D and we
		# don't apply boolean operations to them.
		var open_edges = info.shape.get_open_edges(info.node.transform)
		edges.append_array(open_edges)

	if source_polygons.is_empty():
		return

	## Merge all closed polygons together
	var merged_polygons: Array[ComplexPolygon] = []

	while not source_polygons.is_empty():
		var merged := false
		var p1: ComplexPolygon = source_polygons.pop_back()
		var max_steps: int = source_polygons.size()
		var i = 0

		# Test p1 against every other polygon from source_polygon until a
		# successful merge. If no merge happened, put it in the final array.
		while i < max_steps and not merged:
			i += 1

			# Get the next polygon in the list
			var p2: ComplexPolygon = source_polygons.pop_back()

			# If the outer boundary of any of the two polygons is completely
			# enclosed in one of the other polygon's hole, we don't try to
			# merge them and go the next iteration.
			var full_overlap = false
			for ip1 in p1.inner:
				var res = Geometry2D.clip_polygons(p2.outer, ip1)
				if res.is_empty():
					full_overlap = true
					break

			for ip2 in p2.inner:
				var res = Geometry2D.clip_polygons(p1.outer, ip2)
				if res.is_empty():
					full_overlap = true
					break

			if full_overlap:
				source_polygons.push_front(p2)
				continue

			# Try to merge the two polygons p1 and p2
			var res = Geometry2D.merge_polygons(p1.outer, p2.outer)
			var outer_polygons := 0
			for p in res:
				if not Geometry2D.is_polygon_clockwise(p):
					outer_polygons += 1

			# If the merge generated a new polygon, process the holes data from
			# the two original polygons and store in the new_polygon
			# P1 and P2 are then discarded and replaced by the new polygon.
			if outer_polygons == 1:
				var new_polygon = ComplexPolygon.new()
				new_polygon.add_array(res)

				# Process the holes data from p1 and p2
				for ip1 in p1.inner:
					for ip2 in p2.inner:
						new_polygon.add_array(Geometry2D.intersect_polygons(ip1, ip2), true)
						new_polygon.add_array(Geometry2D.clip_polygons(ip2, p1.outer), true)

					new_polygon.add_array(Geometry2D.clip_polygons(ip1, p2.outer), true)

				source_polygons.push_back(new_polygon)
				merged = true

			# If the polygons don't overlap, return it to the pool to be tested
			# against other polygons
			else:
				source_polygons.push_front(p2)

		# If p1 is not overlapping any other polygon, add it to the final list
		if not merged:
			merged_polygons.push_back(p1)

	## For each polygons from the previous step, create a corresponding Curve3D
	for cp in merged_polygons:
		for polygon in cp.get_all():
			if polygon.size() < 2: # Ignore polygons too small to form a loop
				continue

			var curve := Curve3D.new()
			for point in polygon:
				curve.add_point(Vector3(point.x, 0.0, point.y))

			# Close the look if the last vertex is missing (Randomly happens)
			var first_point := polygon[0]
			var last_point := polygon[-1]
			if first_point != last_point:
				curve.add_point(Vector3(first_point.x, 0.0, first_point.y))

			edges.push_back(curve)


func get_root() -> ProtonScatter:
	return root


func get_global_transform() -> Transform3D:
	return root.get_global_transform()


func get_local_transform() -> Transform3D:
	return root.get_transform()


func get_edges() -> Array[Curve3D]:
	if edges.is_empty():
		compute_edges()
	return edges


func get_copy():
	var copy = get_script().new()

	copy.root = root
	copy.bounds_global = bounds_global
	copy.bounds_local = bounds_local

	for s in positive_shapes:
		var s_copy = DomainShapeInfo.new()
		s_copy.node = s.node
		s_copy.shape = s.shape.get_copy()
		copy.positive_shapes.push_back(s_copy)

	for s in negative_shapes:
		var s_copy = DomainShapeInfo.new()
		s_copy.node = s.node
		s_copy.shape = s.shape.get_copy()
		copy.negative_shapes.push_back(s_copy)

	return copy


func _discover_shapes_recursive(node: Node) -> void:
	if node is ProtonScatter: # Ignore shapes under nested Scatter nodes
		return

	if node is ProtonScatterShape and node.shape != null:
		var info := DomainShapeInfo.new()
		info.node = node
		info.shape = node.shape

		if node.negative:
			negative_shapes.push_back(info)
		else:
			positive_shapes.push_back(info)

	for c in node.get_children():
		_discover_shapes_recursive(c)
