class_name ShorelineRibbonGenerator
extends RefCounted

const WATER_PLANE_EPSILON := 0.001
const ENDPOINT_MERGE_TOLERANCE := 0.03
const LOOP_CLOSURE_TOLERANCE := 0.06
const MINIMUM_FRAGMENT_LENGTH := 2.0
const SIMPLIFICATION_TOLERANCE := 0.025
const SMOOTHING_ITERATIONS := 0
const RESAMPLE_SPACING := 0.10
const RIBBON_WIDTH := 1.35
const LAND_INSET := 0.12
const SURFACE_OFFSET := 0.018


static func generate(
	faces: PackedVector3Array,
	water_height: float,
	bounds: Rect2,
	water_reference: Vector2,
	water_is_inside: bool,
	simplification_override := -1.0,
	smoothing_iterations_override := -1,
	resample_spacing_override := -1.0
) -> Dictionary:
	var simplification := (
		SIMPLIFICATION_TOLERANCE
		if simplification_override < 0.0
		else simplification_override
	)
	var smoothing_iterations := (
		SMOOTHING_ITERATIONS
		if smoothing_iterations_override < 0
		else smoothing_iterations_override
	)
	var resample_spacing := (
		RESAMPLE_SPACING
		if resample_spacing_override < 0.0
		else resample_spacing_override
	)
	var segments := _extract_segments(faces, water_height, bounds)
	var raw_paths := _stitch_segments(segments)
	var processed_paths: Array[Dictionary] = []
	var simplified_paths: Array[Dictionary] = []
	var raw_point_count := 0
	var simplified_point_count := 0
	var smoothed_point_count := 0
	for path_data: Dictionary in raw_paths:
		var raw_points: PackedVector2Array = path_data["points"]
		var closed: bool = path_data["closed"]
		if _path_length(raw_points, closed) < MINIMUM_FRAGMENT_LENGTH:
			continue
		var simplified := _simplify(raw_points, closed, simplification)
		simplified_paths.append({"points": simplified, "closed": closed})
		var smoothed := _chaikin(simplified, closed, smoothing_iterations)
		var resampled := _resample(smoothed, closed, resample_spacing)
		if resampled.size() < (3 if closed else 2):
			continue
		raw_point_count += raw_points.size()
		simplified_point_count += simplified.size()
		smoothed_point_count += resampled.size()
		processed_paths.append({"points": resampled, "closed": closed})
	var mesh := _build_mesh(
		processed_paths,
		water_height,
		water_reference,
		water_is_inside
	)
	return {
		"mesh": mesh,
		"segment_count": segments.size(),
		"loop_count": processed_paths.size(),
		"raw_point_count": raw_point_count,
		"simplified_point_count": simplified_point_count,
		"smoothed_point_count": smoothed_point_count,
		"triangle_count": _mesh_triangle_count(mesh),
		"debug_raw_paths": raw_paths,
		"debug_simplified_paths": simplified_paths,
		"debug_smoothed_paths": processed_paths,
	}


static func _extract_segments(
	faces: PackedVector3Array,
	water_height: float,
	bounds: Rect2
) -> Array[PackedVector2Array]:
	var segments: Array[PackedVector2Array] = []
	for index: int in range(0, faces.size(), 3):
		var triangle: Array[Vector3] = [
			faces[index], faces[index + 1], faces[index + 2]
		]
		var hits := PackedVector2Array()
		for edge: int in 3:
			var a := triangle[edge]
			var b := triangle[(edge + 1) % 3]
			var distance_a := a.y - water_height
			var distance_b := b.y - water_height
			if (
				absf(distance_a) <= WATER_PLANE_EPSILON
				and absf(distance_b) <= WATER_PLANE_EPSILON
			):
				continue
			if not (
				distance_a * distance_b < 0.0
				or absf(distance_a) <= WATER_PLANE_EPSILON
				or absf(distance_b) <= WATER_PLANE_EPSILON
			):
				continue
			var denominator := distance_a - distance_b
			var amount := (
				0.0
				if absf(denominator) <= WATER_PLANE_EPSILON
				else distance_a / denominator
			)
			var hit3 := a.lerp(b, clampf(amount, 0.0, 1.0))
			var hit := Vector2(hit3.x, hit3.z)
			if bounds.has_point(hit) and not _contains_near(hits, hit):
				hits.append(hit)
		if (
			hits.size() == 2
			and hits[0].distance_to(hits[1]) > WATER_PLANE_EPSILON
		):
			segments.append(hits)
	return segments


static func _stitch_segments(
	segments: Array[PackedVector2Array]
) -> Array[Dictionary]:
	var point_by_key: Dictionary = {}
	var adjacency: Dictionary = {}
	var unused_edges: Dictionary = {}
	for segment: PackedVector2Array in segments:
		var a_key := _point_key(segment[0])
		var b_key := _point_key(segment[1])
		if a_key == b_key:
			continue
		point_by_key[a_key] = segment[0]
		point_by_key[b_key] = segment[1]
		if not adjacency.has(a_key):
			adjacency[a_key] = []
		if not adjacency.has(b_key):
			adjacency[b_key] = []
		var edge_key := _edge_key(a_key, b_key)
		if unused_edges.has(edge_key):
			continue
		(adjacency[a_key] as Array).append(b_key)
		(adjacency[b_key] as Array).append(a_key)
		unused_edges[edge_key] = true

	var starts: Array = []
	for key: Vector2i in adjacency:
		if (adjacency[key] as Array).size() != 2:
			starts.append(key)
	for key: Vector2i in adjacency:
		if not starts.has(key):
			starts.append(key)

	var paths: Array[Dictionary] = []
	for start: Vector2i in starts:
		while _has_unused_neighbor(start, adjacency, unused_edges):
			var walked := _walk_path(start, point_by_key, adjacency, unused_edges)
			if (walked["points"] as PackedVector2Array).size() >= 2:
				paths.append(walked)
	return paths


static func _walk_path(
	start: Vector2i,
	point_by_key: Dictionary,
	adjacency: Dictionary,
	unused_edges: Dictionary
) -> Dictionary:
	var points := PackedVector2Array()
	var previous := Vector2i(2147483647, 2147483647)
	var current := start
	var closed := false
	var guard := unused_edges.size() + 2
	while guard > 0:
		guard -= 1
		points.append(point_by_key[current])
		var next_key := Vector2i(2147483647, 2147483647)
		for candidate: Vector2i in adjacency[current]:
			if candidate == previous and (adjacency[current] as Array).size() > 1:
				continue
			if unused_edges.get(_edge_key(current, candidate), false):
				next_key = candidate
				break
		if next_key.x == 2147483647:
			for candidate: Vector2i in adjacency[current]:
				if unused_edges.get(_edge_key(current, candidate), false):
					next_key = candidate
					break
		if next_key.x == 2147483647:
			break
		unused_edges[_edge_key(current, next_key)] = false
		previous = current
		current = next_key
		if current == start:
			closed = true
			break
	return {"points": points, "closed": closed}


static func _simplify(
	points: PackedVector2Array,
	closed: bool,
	tolerance: float
) -> PackedVector2Array:
	if points.size() <= (4 if closed else 2):
		return points
	if not closed:
		return _rdp_open(points, tolerance)
	var split_a := 0
	var split_b := 1
	var greatest_distance := 0.0
	for a: int in points.size():
		for b: int in range(a + 1, points.size()):
			var distance := points[a].distance_squared_to(points[b])
			if distance > greatest_distance:
				greatest_distance = distance
				split_a = a
				split_b = b
	var first_arc := _closed_arc(points, split_a, split_b)
	var second_arc := _closed_arc(points, split_b, split_a)
	var first_result := _rdp_open(first_arc, tolerance)
	var second_result := _rdp_open(second_arc, tolerance)
	var result := PackedVector2Array()
	for index: int in first_result.size() - 1:
		result.append(first_result[index])
	for index: int in second_result.size() - 1:
		result.append(second_result[index])
	return result if result.size() >= 4 else points


static func _rdp_open(
	points: PackedVector2Array,
	tolerance: float
) -> PackedVector2Array:
	if points.size() <= 2:
		return points
	var greatest_distance := 0.0
	var split_index := 0
	for index: int in range(1, points.size() - 1):
		var distance := _point_segment_distance(
			points[index], points[0], points[points.size() - 1]
		)
		if distance > greatest_distance:
			greatest_distance = distance
			split_index = index
	if greatest_distance <= tolerance:
		return PackedVector2Array([points[0], points[points.size() - 1]])
	var left := _rdp_open(points.slice(0, split_index + 1), tolerance)
	var right := _rdp_open(points.slice(split_index), tolerance)
	var result := PackedVector2Array()
	for index: int in left.size() - 1:
		result.append(left[index])
	result.append_array(right)
	return result


static func _closed_arc(
	points: PackedVector2Array,
	start: int,
	finish: int
) -> PackedVector2Array:
	var result := PackedVector2Array()
	var index := start
	result.append(points[index])
	while index != finish:
		index = (index + 1) % points.size()
		result.append(points[index])
	return result


static func _chaikin(
	points: PackedVector2Array,
	closed: bool,
	iterations: int
) -> PackedVector2Array:
	var result := points
	for _iteration: int in iterations:
		var next := PackedVector2Array()
		if not closed:
			next.append(result[0])
		var edge_count := result.size() if closed else result.size() - 1
		for index: int in edge_count:
			var a := result[index]
			var b := result[(index + 1) % result.size()]
			next.append(a.lerp(b, 0.25))
			next.append(a.lerp(b, 0.75))
		if not closed:
			next.append(result[result.size() - 1])
		result = next
	return result


static func _resample(
	points: PackedVector2Array,
	closed: bool,
	spacing: float
) -> PackedVector2Array:
	var total_length := _path_length(points, closed)
	if total_length <= spacing:
		return points
	var count := maxi(roundi(total_length / spacing), 3 if closed else 2)
	var actual_spacing := total_length / float(count if closed else count - 1)
	var result := PackedVector2Array()
	var edge := 0
	var edge_start_distance := 0.0
	var edge_length := points[0].distance_to(points[1])
	for sample: int in count:
		var target := actual_spacing * sample
		while target > edge_start_distance + edge_length and edge < points.size() - 1:
			edge_start_distance += edge_length
			edge += 1
			if edge >= points.size() - 1:
				edge_length = points[edge].distance_to(points[0]) if closed else 0.0
			else:
				edge_length = points[edge].distance_to(points[edge + 1])
		var next_index := (edge + 1) % points.size()
		var amount := (
			0.0
			if edge_length <= WATER_PLANE_EPSILON
			else (target - edge_start_distance) / edge_length
		)
		result.append(points[edge].lerp(points[next_index], clampf(amount, 0.0, 1.0)))
	return result


static func _build_mesh(
	paths: Array[Dictionary],
	water_height: float,
	water_reference: Vector2,
	water_is_inside: bool
) -> ArrayMesh:
	var vertices := PackedVector3Array()
	var normals := PackedVector3Array()
	var uvs := PackedVector2Array()
	var indices := PackedInt32Array()
	for path_data: Dictionary in paths:
		var points: PackedVector2Array = path_data["points"]
		var closed: bool = path_data["closed"]
		var closed_water_side := (
			_closed_path_water_side(points, water_is_inside)
			if closed
			else 0.0
		)
		var base_index := vertices.size()
		var path_distance := 0.0
		for index: int in points.size():
			if index > 0:
				path_distance += points[index - 1].distance_to(points[index])
			var previous := points[(index - 1 + points.size()) % points.size()]
			var following := points[(index + 1) % points.size()]
			if not closed:
				previous = points[maxi(index - 1, 0)]
				following = points[mini(index + 1, points.size() - 1)]
			var point := points[index]
			var incoming := previous.direction_to(point)
			var outgoing := point.direction_to(following)
			if incoming.is_zero_approx():
				incoming = outgoing
			if outgoing.is_zero_approx():
				outgoing = incoming
			var incoming_normal := Vector2(-incoming.y, incoming.x)
			var outgoing_normal := Vector2(-outgoing.y, outgoing.x)
			var water_normal := incoming_normal + outgoing_normal
			if water_normal.is_zero_approx():
				water_normal = outgoing_normal
			water_normal = water_normal.normalized()
			if closed:
				water_normal *= closed_water_side
			else:
				var toward_reference := point.direction_to(water_reference)
				if (
					(water_is_inside and water_normal.dot(toward_reference) < 0.0)
					or (not water_is_inside and water_normal.dot(toward_reference) > 0.0)
				):
					water_normal = -water_normal
			var join_scale := minf(
				1.0 / maxf(absf(water_normal.dot(outgoing_normal)), 0.55),
				1.65,
			)
			var land_point := (
				point - water_normal * LAND_INSET * join_scale
			)
			var water_point := (
				point
				+ water_normal * (RIBBON_WIDTH - LAND_INSET) * join_scale
			)
			vertices.append(Vector3(land_point.x, water_height + SURFACE_OFFSET, land_point.y))
			vertices.append(Vector3(water_point.x, water_height + SURFACE_OFFSET, water_point.y))
			normals.append(Vector3.UP)
			normals.append(Vector3.UP)
			uvs.append(Vector2(path_distance, 0.0))
			uvs.append(Vector2(path_distance, 1.0))
		var edge_count := points.size() if closed else points.size() - 1
		for index: int in edge_count:
			var next := (index + 1) % points.size()
			var a := base_index + index * 2
			var b := a + 1
			var c := base_index + next * 2
			var d := c + 1
			indices.append_array(PackedInt32Array([a, c, b, b, c, d]))
	var arrays := []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = vertices
	arrays[Mesh.ARRAY_NORMAL] = normals
	arrays[Mesh.ARRAY_TEX_UV] = uvs
	arrays[Mesh.ARRAY_INDEX] = indices
	var mesh := ArrayMesh.new()
	if not vertices.is_empty():
		mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
	return mesh


static func _closed_path_water_side(
	points: PackedVector2Array,
	water_is_inside: bool,
) -> float:
	var signed_area_twice := 0.0
	for index: int in points.size():
		signed_area_twice += points[index].cross(
			points[(index + 1) % points.size()]
		)
	var interior_side := 1.0 if signed_area_twice >= 0.0 else -1.0
	return interior_side if water_is_inside else -interior_side


static func _point_key(point: Vector2) -> Vector2i:
	return Vector2i(
		roundi(point.x / ENDPOINT_MERGE_TOLERANCE),
		roundi(point.y / ENDPOINT_MERGE_TOLERANCE)
	)


static func _edge_key(a: Vector2i, b: Vector2i) -> String:
	if a.x < b.x or (a.x == b.x and a.y <= b.y):
		return "%d:%d|%d:%d" % [a.x, a.y, b.x, b.y]
	return "%d:%d|%d:%d" % [b.x, b.y, a.x, a.y]


static func _has_unused_neighbor(
	key: Vector2i,
	adjacency: Dictionary,
	unused_edges: Dictionary
) -> bool:
	for neighbor: Vector2i in adjacency[key]:
		if unused_edges.get(_edge_key(key, neighbor), false):
			return true
	return false


static func _contains_near(points: PackedVector2Array, point: Vector2) -> bool:
	for existing: Vector2 in points:
		if existing.distance_to(point) <= WATER_PLANE_EPSILON:
			return true
	return false


static func _point_segment_distance(point: Vector2, a: Vector2, b: Vector2) -> float:
	var segment := b - a
	if segment.length_squared() <= WATER_PLANE_EPSILON:
		return point.distance_to(a)
	var amount := clampf((point - a).dot(segment) / segment.length_squared(), 0.0, 1.0)
	return point.distance_to(a + segment * amount)


static func _path_length(points: PackedVector2Array, closed: bool) -> float:
	var result := 0.0
	for index: int in points.size() - 1:
		result += points[index].distance_to(points[index + 1])
	if closed and points.size() > 2:
		result += points[points.size() - 1].distance_to(points[0])
	return result


static func _mesh_triangle_count(mesh: ArrayMesh) -> int:
	if mesh.get_surface_count() == 0:
		return 0
	var arrays := mesh.surface_get_arrays(0)
	return floori(
		float((arrays[Mesh.ARRAY_INDEX] as PackedInt32Array).size()) / 3.0
	)
