class_name UpgradeTreeCanvas
extends Control

signal node_selected(id: String)
signal node_hovered(id: String)
signal node_unhovered(id: String)

const NODE_SIZE := Vector2(118, 92)
## Rayon d'evitement d'un noeud: la moitie de son gabarit, plus la marge qui
## empeche un segment de raser un contour.
const NODE_CLEARANCE := 52.0

const REVEAL_SHADER = preload("res://scripts/progression/upgrade_reveal.gdshader")
const REVEAL_DURATION := 0.85
var previous_states: Dictionary = {}
var reveals: Dictionary = {}
var has_built := false

var positions: Dictionary = {}
var node_controls: Dictionary = {}
## La pastille que le clavier designe, ou rien. Ce n'est pas une selection: sur
## cet ecran il n'y a rien a selectionner. C'est le doigt de celui qui joue aux
## fleches, faute de pointeur a suivre — la souris l'efface.
var cursor_id := ""

func _ready() -> void:
	resized.connect(rebuild)
	call_deferred("rebuild")

func rebuild() -> void:
	if not is_inside_tree() or size.x <= 0.0:
		return
	for child: Node in get_children():
		remove_child(child)
		child.queue_free()
	positions.clear()
	node_controls.clear()
	var root_id := _root_id()
	if root_id.is_empty():
		return
	positions[root_id] = Vector2(size.x * 0.48, size.y * 0.50)
	for id: String in DataCatalog.UPGRADES:
		_compute_position(id, {})
	for id: String in DataCatalog.UPGRADES:
		# Un niveau empile n'a pas de pastille a lui: il se montre dans la
		# coquille de sa tete, qui presente le niveau ou le joueur est rendu.
		if bool(DataCatalog.UPGRADES[id].get("stacks_on_parent", false)):
			continue
		var active := GameState.upgrade_shell_active(id)
		var state := GameState.get_upgrade_state(active)
		var before := str(previous_states.get(id, "invisible"))
		if has_built and state != "invisible" and (before == "invisible" or (before in ["mystery", "locked"] and state in ["available", "insufficient"])):
			if not reveals.has(id):
				reveals[id] = 0.0
		previous_states[id] = state

		if state == "invisible":
			continue
		var node := UpgradeTreeNode.new()
		# La pastille porte l'identite du niveau ou l'on est rendu: la cliquer
		# achete CE niveau, et le panneau de details parle de lui. La coquille,
		# elle, n'existe que comme place et comme compteur.
		node.setup(active, DataCatalog.UPGRADES[active], state, GameState.upgrade_shell_progress(id))
		# Le gabarit depend du theme: on centre sur la taille reellement prise.
		node.position = Vector2(positions[id]) - node.custom_minimum_size * 0.5
		node.node_selected.connect(func(selected_id: String) -> void: node_selected.emit(selected_id))
		node.node_hovered.connect(func(hovered_id: String) -> void: node_hovered.emit(hovered_id))
		node.node_unhovered.connect(func(unhovered_id: String) -> void: node_unhovered.emit(unhovered_id))
		add_child(node)
		node_controls[id] = node
	has_built = true
	_apply_cursor()
	_apply_reveals()
	queue_redraw()

func _process(delta: float) -> void:
	if reveals.is_empty():
		return
	for id: String in reveals.keys():
		reveals[id] = minf(REVEAL_DURATION, float(reveals[id]) + delta)
		if float(reveals[id]) >= REVEAL_DURATION or not node_controls.has(id):
			reveals.erase(id)
	_apply_reveals()
	queue_redraw()

func _reveal_progress(id: String) -> float:
	return clampf(float(reveals.get(id, REVEAL_DURATION)) / REVEAL_DURATION, 0.0, 1.0)

static func _path_length(points: PackedVector2Array) -> float:
	var length := 0.0
	for index: int in range(1, points.size()):
		length += points[index - 1].distance_to(points[index])
	return length

static func _path_prefix(points: PackedVector2Array, distance: float) -> PackedVector2Array:
	if points.is_empty() or distance <= 0.0:
		return PackedVector2Array()
	var result := PackedVector2Array([points[0]])
	for index: int in range(1, points.size()):
		var segment := points[index - 1].distance_to(points[index])
		if distance < segment:
			result.append(points[index - 1].lerp(points[index], distance / maxf(segment, 0.001)))
			return result
		result.append(points[index])
		distance -= segment
	return result

func _reveal_geometry(id: String, parent: String = "") -> Dictionary:
	var node: Control = node_controls[id]
	if parent.is_empty():
		parent = str(DataCatalog.UPGRADES[id].get("parent", ""))
	var points := PackedVector2Array()
	if node_controls.has(parent) and bool(DataCatalog.UPGRADES[id].get("show_parent_link", true)):
		points = _connector_points(Vector2(positions[parent]), Vector2(positions[id]), [parent, id])
	var axis := Vector2.RIGHT
	if points.size() >= 2:
		axis = (points[-1] - points[-2]).normalized()
	var half := absf(axis.x) * node.size.x * 0.5 + absf(axis.y) * node.size.y * 0.5
	var length := _path_length(points)
	# Without a link, sweep from the near edge directly across the node.
	var start := 0.0 if not points.is_empty() else -half
	var distance := lerpf(start, length + half + 7.0, _reveal_progress(id))
	return {"points": points, "axis": axis, "front": distance - length, "distance": distance}

func _apply_reveals() -> void:
	for id: String in node_controls:
		var node: Control = node_controls[id]
		if not reveals.has(id):
			node.material = null
			node.mouse_filter = Control.MOUSE_FILTER_STOP
			continue
		var geometry := _reveal_geometry(id)
		var reveal_material := node.material as ShaderMaterial
		if reveal_material == null:
			reveal_material = ShaderMaterial.new()
			reveal_material.shader = REVEAL_SHADER
			node.material = reveal_material
		reveal_material.set_shader_parameter("reveal_axis", geometry.axis)
		reveal_material.set_shader_parameter("reveal_center", node.size * 0.5)
		reveal_material.set_shader_parameter("reveal_front", geometry.front)
		node.mouse_filter = Control.MOUSE_FILTER_IGNORE

func _revealed_connector(id: String, parent: String) -> PackedVector2Array:
	var points := _connector_points(Vector2(positions[parent]), Vector2(positions[id]), [parent, id])
	if not reveals.has(id):
		return points
	var geometry := _reveal_geometry(id, parent)
	return _path_prefix(points, float(geometry.distance))

## Poser le curseur clavier sur un niveau. On recoit l'identite d'un NIVEAU —
## c'est elle qui circule depuis un clic; la pastille est celle de sa coquille.
func focus_node(id: String) -> void:
	var shell := GameState.upgrade_shell_of(id)
	cursor_id = shell if node_controls.has(shell) else ""
	_apply_cursor()

## L'effacer. La souris entre sur un noeud: le doigt du clavier n'a plus lieu
## d'etre, et deux marques a l'ecran laisseraient croire a deux etats.
func clear_cursor() -> void:
	if cursor_id.is_empty():
		return
	cursor_id = ""
	_apply_cursor()

func _apply_cursor() -> void:
	for id: String in node_controls:
		var node := node_controls[id] as UpgradeTreeNode
		if node == null:
			continue
		var wanted := id == cursor_id
		if node.cursor != wanted:
			node.cursor = wanted
			node.queue_redraw()

func first_visible_node() -> String:
	for id: String in DataCatalog.UPGRADES:
		if node_controls.has(id):
			return GameState.upgrade_shell_active(id)
	return ""

func navigate_from(id: String, direction: Vector2) -> String:
	var shell := GameState.upgrade_shell_of(id)
	if not positions.has(shell):
		return first_visible_node()
	var origin: Vector2 = positions[shell]
	var best_id := ""
	var best_score := INF
	for candidate_id: String in node_controls:
		if candidate_id == shell or reveals.has(candidate_id):
			continue
		var offset: Vector2 = Vector2(positions[candidate_id]) - origin
		if offset.length_squared() < 1.0 or offset.normalized().dot(direction) < 0.55:
			continue
		var score := offset.length() * (1.5 - offset.normalized().dot(direction))
		if score < best_score:
			best_score = score
			best_id = candidate_id
	# On rend le NIVEAU a selectionner, comme le ferait un clic sur la pastille.
	return GameState.upgrade_shell_active(best_id) if not best_id.is_empty() else ""

## Cap en degres depuis le parent: 0 au nord, sens horaire. Le y de l'ecran
## descend, d'ou le sinus en x et le cosinus negatif en y.
static func angle_vector(angle: float) -> Vector2:
	var radians := deg_to_rad(angle)
	return Vector2(sin(radians), -cos(radians))

func _compute_position(id: String, visiting: Dictionary) -> Vector2:
	if positions.has(id):
		return positions[id]
	if visiting.has(id):
		return Vector2.ZERO
	visiting[id] = true
	var definition: Dictionary = DataCatalog.UPGRADES[id]
	var parent := str(definition.get("parent", ""))
	if parent.is_empty() or not DataCatalog.UPGRADES.has(parent):
		positions[id] = Vector2(size.x * 0.48, size.y * 0.50)
	elif bool(definition.get("stacks_on_parent", false)):
		# Un niveau ne se place pas: il EST a la place de sa coquille. Son cap et
		# sa distance ne sont plus lus — c'est ce qui fait que le lien vers la
		# suite part de la coquille sans qu'on ait rien a dire de plus.
		positions[id] = _compute_position(parent, visiting)
	else:
		var parent_position := _compute_position(parent, visiting)
		var vector := angle_vector(float(definition.get("angle", 0.0)))
		positions[id] = parent_position + vector * float(definition.get("distance", 150.0))
	visiting.erase(id)
	return positions[id]

func _root_id() -> String:
	for id: String in DataCatalog.UPGRADES:
		if str(DataCatalog.UPGRADES[id].get("parent", "")).is_empty():
			return id
	return ""

func _draw() -> void:
	# Un Control annonce a Godot que son dessin tient dans SON rectangle — ici la
	# taille de la fenetre, ancree a l origine de l arbre. Les segments, eux, vont
	# la ou sont les noeuds, souvent tres loin de cette origine. Zoome dedans, on
	# est panne loin: le rectangle annonce sort de l ecran et Godot elague tout le
	# dessin d un coup — plus une seule liaison, alors que les noeuds restent, eux
	# qui portent chacun son propre rectangle. Laisser Godot mesurer ce qu on
	# dessine vraiment le remet a sa place.
	RenderingServer.canvas_item_set_custom_rect(get_canvas_item(), false)
	for id: String in DataCatalog.UPGRADES:
		if not node_controls.has(id):
			continue
		var definition: Dictionary = DataCatalog.UPGRADES[id]
		if not bool(definition.get("show_parent_link", true)):
			continue
		# Un prerequis pose au milieu d'une suite se relie a la COQUILLE de
		# cette suite: c'est la seule pastille qui existe a l'ecran.
		var parent := GameState.upgrade_shell_of(str(definition.get("parent", "")))
		if parent.is_empty() or not node_controls.has(parent) or parent == id:
			continue
		var theme_id := str(definition.get("theme", ""))
		var theme: Dictionary = DataCatalog.UPGRADE_THEMES.get(theme_id, DataCatalog.DEFAULT_UPGRADE_THEMES.values()[0])
		var states: Dictionary = theme.get("states", {})
		var style: Dictionary = states.get(GameState.get_upgrade_state(id), states.get("available", {}))
		_draw_connector(_revealed_connector(id, parent), style)
		for raw_secondary_parent: Variant in definition.get("secondary_parents", []):
			var secondary_parent := GameState.upgrade_shell_of(str(raw_secondary_parent))
			if not node_controls.has(secondary_parent) or secondary_parent == id:
				continue
			# Un prerequis est un prerequis: le segment d'un parent secondaire se
			# dessine comme celui du parent principal. L'amincir et le pointiller
			# laissait croire a un lien facultatif, alors qu'il faut l'acheter lui
			# aussi pour que l'emplacement apparaisse.
			_draw_connector(_revealed_connector(id, secondary_parent), style)

## Un segment qui traverse un noeud sans rapport rend l'arbre illisible: il
## passe a cote. Le noeud le plus enfonce dans le passage decide du cote et de
## l'ampleur du detour; la quadratique qui en sort est echantillonnee, deux
## obstacles alignes sur le meme segment restant l'exception.
func _connector_points(from: Vector2, to: Vector2, endpoints: Array) -> PackedVector2Array:
	var straight := PackedVector2Array([from, to])
	var offset := to - from
	var length := offset.length()
	if length < 1.0:
		return straight
	var forward := offset / length
	var side_axis := Vector2(-forward.y, forward.x)
	var worst_along := 0.0
	var worst_side := 0.0
	var worst_penetration := 0.0
	for id: String in positions:
		if endpoints.has(id) or not node_controls.has(id):
			continue
		var local: Vector2 = Vector2(positions[id]) - from
		var along := local.dot(forward)
		if along <= NODE_CLEARANCE * 0.35 or along >= length - NODE_CLEARANCE * 0.35:
			continue
		var side := local.dot(side_axis)
		var penetration := NODE_CLEARANCE - absf(side)
		if penetration <= worst_penetration:
			continue
		worst_penetration = penetration
		worst_along = along
		worst_side = side
	if worst_penetration <= 0.0:
		return straight
	# Le point de controle est tire deux fois plus loin que l'ecart voulu: une
	# quadratique ne passe qu'a mi-chemin de son controle.
	var away := -1.0 if worst_side >= 0.0 else 1.0
	var push := (worst_penetration + NODE_CLEARANCE * 0.5) * 2.0 * away
	var control := from + forward * worst_along + side_axis * push
	var points := PackedVector2Array()
	for step: int in range(13):
		var t := float(step) / 12.0
		points.append(from.lerp(control, t).lerp(control.lerp(to, t), t))
	return points

## Sous un pixel a l'ecran, un trait antialiase n'a plus rien a repartir et
## disparait — d'un coup, pas progressivement. C'est ce qui faisait s'evanouir
## tous les segments en dezoomant pendant que les noeuds, eux, restaient: un
## segment de deux pixels a 0,4 de zoom n'en fait plus que huit dixiemes.
const MIN_CONNECTOR_SCREEN_WIDTH := 1.6

## L'epaisseur a poser dans le repere du canvas pour obtenir au moins un trait
## visible a l'ecran. Le zoom multiplie tout ce qu'on dessine ici: une
## epaisseur ecrite telle quelle retrecit avec lui.
static func connector_width_for(width: float, zoom: float) -> float:
	return maxf(width, MIN_CONNECTOR_SCREEN_WIDTH / maxf(0.01, absf(zoom)))

func _draw_connector(points: PackedVector2Array, style: Dictionary) -> void:
	if points.size() < 2:
		return
	var color_value: Variant = style.get("connector_color", Color("64748b"))
	var color: Color = color_value if color_value is Color else Color.from_string(str(color_value), Color("64748b"))
	var width := float(style.get("connector_width", 3.0))
	if width <= 0.0:
		return
	# `scale` porte le zoom de la vue: c'est lui qui decide de ce que cette
	# epaisseur devient a l'ecran.
	width = connector_width_for(width, scale.x)
	var pattern := str(style.get("connector_pattern", "solid"))
	if pattern == "solid":
		draw_polyline(points, color, width, true)
		return
	var dash := maxf(8.0, width * 2.5) if pattern == "dashed" else maxf(2.0, width)
	for index: int in range(points.size() - 1):
		draw_dashed_line(points[index], points[index + 1], color, width, dash, true, true)
