extends Node

# Un personnage qu'on aborde parle par-dessus la partie, et dit autre chose
# selon l'état du jeu:
#
# 1. Parler à un PNJ qui incarne un personnage ouvre son dialogue: la partie se
#    fige, la boîte prend son style et porte son nom.
# 2. La première conversation joue la scène « jamais parlé »; cette scène donne
#    un objet de quête par son événement « valeur ».
# 3. La fin du dialogue rend la main et compte la conversation; la suivante
#    joue la réplique d'après.
# 4. Le portrait (ligne 38): la fiche porte son réglage, chaque réplique dit qui
#    parle; le portrait suit, cadré et placé comme demandé, et se cache pour
#    la narration ou un personnage sans portrait.
# 5. Un avis de la brigade lu pendant un dialogue (ligne 97) rend la pause du
#    dialogue, au lieu de relancer la partie sous lui.
#
# Le contrôle fabrique ses scènes, son personnage et son PNJ.

const DIALOGUE := preload("res://scripts/core/dialogue_overlay.gd")
const STAFF_NOTICE := preload("res://scripts/core/staff_notice.gd")
const CHARACTER := "probe_character"
const FIRST := "probe_talk_first"
const AGAIN := "probe_talk_again"
const TOKEN := "probe_token_item"
const NPC := "probe_npc_element"
const OTHER := "probe_other_character"
const PORTRAIT_SCENE := "probe_portrait_scene"
const PORTRAIT_SOURCE := "probe_portrait_model"
var is_probe := false
var failures := 0

func _ready() -> void:
	if is_probe:
		return
	GameState.save_locked = true
	var probe := Node.new()
	probe.set_script(get_script())
	probe.set("is_probe", true)
	probe.process_mode = Node.PROCESS_MODE_ALWAYS
	get_tree().root.add_child.call_deferred(probe)
	probe.call_deferred("_run")

func check(ok: bool, message: String) -> void:
	if not ok:
		failures += 1
		push_error("DIALOGUE: " + message)

func _frames(count: int = 4) -> void:
	for frame: int in count:
		await get_tree().process_frame

func _scene(text: String, gives: bool) -> Dictionary:
	var events: Array = [{"id": "line", "type": "text", "text": text, "duration": 0.0}]
	if gives:
		events.append({"id": "give", "type": "set_value", "path": "key_items.%s.owned" % TOKEN, "operation": "add", "value": 1})
	events.append({"id": "close", "type": "end"})
	return {"display_name": text, "description": "", "backdrop_color": Color(0, 0, 0, 0.4), "backdrop_visual": "",
		"skippable": true, "actors": [], "events": events}

func _fixture() -> void:
	GameState._reset_progress_state()
	DataCatalog.KEY_ITEMS[TOKEN] = {"display_name": "Token", "description": "", "color": Color("f4d35e"),
		"visual": "", "scope": "meta", "consumed_on_use": false, "lost_on_ko": false, "max_owned": 5}
	DataCatalog.STORY_SCENES[FIRST] = _scene("Hello, stranger.", true)
	DataCatalog.STORY_SCENES[AGAIN] = _scene("Back again?", false)
	DataCatalog.CHARACTERS[CHARACTER] = {"display_name": "Probe", "description": "", "color": Color("ffd166"),
		"portrait": "", "style": {"panel_color": Color("112233"), "border_color": Color("445566"),
			"text_color": Color("eeeeee"), "name_color": Color("ffaa00"), "font_size": 30},
		"dialogues": [
			{"scene": FIRST, "conditions": [{"path": "characters.%s.times_talked" % CHARACTER, "operator": "==", "value": 0}]},
			{"scene": AGAIN, "conditions": []},
		]}
	DataCatalog.ROOM_ELEMENTS[NPC] = {"display_name": "Stall", "description": "", "kind": "npc", "shape": "square",
		"color": Color("8a6a3d"), "size": 1.0, "health": 0.0, "solid": true, "damage": 0.0, "trigger_radius": 0.0,
		"rearm_delay": 0.0, "coins": 0, "drops": [], "visuals": {}, "vfx": {}, "model_3d": {},
		"required_item": "", "character": CHARACTER}

func _run() -> void:
	await _frames()
	_fixture()
	# Le trajet des données: ce que le gestionnaire publie arrive en jeu.
	var loaded: Variant = DataCatalog._normalize_characters({"authored": {"display_name": "A", "color": "#123456",
		"style": {"panel_color": "#11223380", "font_size": 20},
		"dialogues": [{"scene": FIRST, "conditions": [{"path": "fact.dimensions_cleared", "operator": ">=", "value": 1}]}]}},
		{}, DataCatalog.STORY_SCENES, {"game_facts": DataCatalog.GAME_FACTS, "game_variables": DataCatalog.GAME_VARIABLES})
	var authored: Dictionary = (loaded as Dictionary).get("authored", {}) if loaded is Dictionary else {}
	check(int(authored.get("style", {}).get("font_size", 0)) == 20
		and (authored.get("style", {}).get("panel_color", Color.WHITE) as Color).is_equal_approx(Color("11223380"))
		and authored.get("dialogues", []).size() == 1 and (authored.dialogues[0].conditions as Array).size() == 1,
		"the catalogue loses a character's style or lines")
	check(DataCatalog._normalize_characters({"bad": {"display_name": "B", "dialogues": [{"scene": "missing"}]}},
		{}, DataCatalog.STORY_SCENES, {}) == null, "a character may name a scene that does not exist")
	var npc := RoomElement.new()
	add_child(npc)
	npc.setup(NPC)
	check(npc.get_prompt() == Loc.ui("ui.element.talk") % "Probe", "the NPC does not offer to talk with its character's name")
	check(GameState.character_dialogue_scene(CHARACTER) == FIRST, "the first conversation does not pick the first line")

	# --- 1 et 2. Première conversation ------------------------------------------
	check(npc.interact(), "talking to the NPC failed")
	await _frames(3)
	var overlay: Node = DIALOGUE.current
	check(overlay != null and is_instance_valid(overlay), "no dialogue opened")
	if overlay == null:
		await _finish(npc)
		return
	check(get_tree().paused, "the game keeps running under the dialogue")
	check(str(overlay.scene_id) == FIRST, "the dialogue did not play the first line")
	var story: Node = overlay.story
	check(story.speaker_text() == "Probe", "the dialogue box does not name who speaks")
	check(story.text_panel_color().is_equal_approx(Color("112233")), "the dialogue box ignores the character style")
	check(npc.interact() and DIALOGUE.current == overlay, "a second gesture opened another dialogue")
	story.continue_scene()
	await _frames(4)
	check(DIALOGUE.current == null and not get_tree().paused, "the dialogue did not give the game back")
	check(GameState.key_item_amount(TOKEN) == 1, "the scene did not give its quest item")
	check(int(Registry.read("characters.%s.times_talked" % CHARACTER, 0)) == 1, "the conversation was not counted")

	# --- 3. La suivante -------------------------------------------------------------
	check(GameState.character_dialogue_scene(CHARACTER) == AGAIN, "a second conversation still picks the first line")
	npc.interact()
	await _frames(3)
	overlay = DIALOGUE.current
	check(overlay != null and str(overlay.scene_id) == AGAIN, "the second conversation did not play the next line")
	if overlay != null:
		overlay.story.continue_scene()
		await _frames(4)
	check(GameState.key_item_amount(TOKEN) == 1, "the second line gave the item again")
	await _check_portrait()
	await _check_notice_under_dialogue()
	await _finish(npc)

# --- 5. Un avis par-dessus un dialogue -------------------------------------------
func _check_notice_under_dialogue() -> void:
	var overlay := DIALOGUE.play_scene(self, AGAIN)
	await _frames(3)
	check(overlay != null and get_tree().paused, "the notice fixture dialogue did not open")
	if overlay == null:
		return
	var notice: CanvasLayer = STAFF_NOTICE.new()
	add_child(notice)
	notice.call("ask", "Heading", "Body", "OK")
	await _frames(3)
	check(bool(notice.call("is_open")), "a staff notice could not show over the dialogue")
	(notice.get("yes_button") as Button).pressed.emit()
	await _frames(3)
	check(get_tree().paused and DIALOGUE.current == overlay,
		"answering a staff notice unfroze the game under a playing dialogue")
	overlay.story.continue_scene()
	await _frames(4)
	check(DIALOGUE.current == null and not get_tree().paused, "the dialogue under the notice did not give the game back")
	notice.queue_free()

func _check_portrait() -> void:
	# --- 4. Le portrait -------------------------------------------------------------
	var shaped: Variant = DataCatalog._normalize_portrait_3d({"source": "enemies:%s" % PORTRAIT_SOURCE,
		"width": 300, "shape": "circle", "border_color": "#ff0000"}, {"enemies": {PORTRAIT_SOURCE: {}}})
	check(shaped is Dictionary and int(shaped.width) == 300 and str(shaped.shape) == "circle"
		and (shaped.border_color as Color).is_equal_approx(Color.RED) and is_equal_approx(float(shaped.span), 0.45),
		"the catalogue loses a portrait setting or its defaults")
	check(DataCatalog._normalize_portrait_3d({"shape": "star"}, {}) == null, "a portrait accepts an unknown shape")
	var aimed: Variant = DataCatalog._normalize_portrait_3d({"aim_bone": "Bone_047"}, {})
	check(aimed is Dictionary and str(aimed.aim_bone) == "Bone_047", "the catalogue loses the portrait's aimed bone")
	check(DataCatalog._normalize_portrait_3d({"source": "enemies:ghost"}, {"enemies": {}}) == null,
		"a portrait may borrow a model that does not exist")
	var bare: Variant = DataCatalog._normalize_characters({"bare": {"display_name": "B"}}, {}, {}, {})
	check(bare is Dictionary and str(bare.bare.portrait_3d.source).is_empty(), "a character without portrait gains one")
	var line := {"id": "l", "type": "text", "text": "x"}
	var no_ids: Array[String] = []
	check(DataCatalog._normalize_story_event(line.merged({"speaker": "ghost"}), no_ids, no_ids, {}, {"characters": {}}) == null,
		"a line may name a speaker who does not exist")
	var narrated: Variant = DataCatalog._normalize_story_event(line.merged({"speaker": "none"}), no_ids, no_ids, {}, {"characters": {}})
	check(narrated is Dictionary and str(narrated.speaker) == "none", "a narrated line loses its speaker")

	# Le modèle du héros sert de sonde, sous une fiche de contrôle: aucune fiche
	# de l'auteur n'est empruntée comme personnage.
	DataCatalog.ENEMIES[PORTRAIT_SOURCE] = {"model_3d": DataCatalog.active_player_config().get("model_3d", {})}
	var setting: Variant = DataCatalog._normalize_portrait_3d({"source": "enemies:%s" % PORTRAIT_SOURCE,
		"anchor": "top_right", "width": 300, "height": 200, "span": 0.5}, {})
	DataCatalog.CHARACTERS[CHARACTER]["portrait_3d"] = setting
	DataCatalog.CHARACTERS[OTHER] = {"display_name": "Other", "description": "", "color": Color("ffffff"),
		"portrait": "", "portrait_3d": DataCatalog._normalize_portrait_3d({}, {}), "style": {}, "dialogues": []}
	var scene := _scene("Mine.", false)
	scene.events = [
		{"id": "own", "type": "text", "text": "Mine.", "duration": 0.0, "speaker": ""},
		{"id": "told", "type": "text", "text": "Told.", "duration": 0.0, "speaker": "none"},
		{"id": "other", "type": "text", "text": "Theirs.", "duration": 0.0, "speaker": OTHER},
		{"id": "close", "type": "end"}]
	DataCatalog.STORY_SCENES[PORTRAIT_SCENE] = scene
	var overlay: Node = DIALOGUE.play_scene(self, PORTRAIT_SCENE, CHARACTER)
	await _frames(3)
	check(overlay != null, "the portrait scene did not open")
	if overlay == null:
		return
	var story: Node = overlay.story
	var frame: Control = story.portrait()
	check(frame.visible and bool(frame.has_model), "the speaker's portrait is not shown")
	check(frame.frame_rect().is_equal_approx(Rect2(Vector2(1920.0 - 300.0 - 24.0, 24.0), Vector2(300.0, 200.0))),
		"the portrait ignores its anchor or size: %s" % frame.frame_rect())
	var lens: Camera3D = frame.camera()
	if lens != null:
		var aim: Vector3 = frame.aim_point()
		var reach := lens.position.distance_to(aim)
		var expected := float(frame.model_height()) * 0.5 * 0.5 / tan(deg_to_rad(lens.fov * 0.5))
		check(absf(reach - expected) <= expected * 0.001, "the camera does not frame the chosen span: %.2f vs %.2f" % [reach, expected])
		check(lens.position.z > aim.z, "the camera does not face the model")
	check(story.speaker_text() == "Probe", "the scene's own line lost its speaker")
	story.continue_scene()
	await _frames(2)
	check(not frame.visible and story.speaker_text().is_empty(), "narration still shows a name or a portrait")
	story.continue_scene()
	await _frames(2)
	check(story.speaker_text() == "Other" and not frame.visible, "another speaker's line keeps the wrong name or portrait")
	story.continue_scene()
	await _frames(4)
	check(DIALOGUE.current == null and not (is_instance_valid(frame) and frame.visible), "the portrait outlives its scene")
	DataCatalog.CHARACTERS.erase(OTHER)
	DataCatalog.STORY_SCENES.erase(PORTRAIT_SCENE)
	DataCatalog.ENEMIES.erase(PORTRAIT_SOURCE)

func _finish(npc: Node) -> void:
	get_tree().paused = false
	if is_instance_valid(npc):
		npc.queue_free()
	GameState.key_items.clear()
	DataCatalog.CHARACTERS.erase(CHARACTER)
	DataCatalog.STORY_SCENES.erase(FIRST)
	DataCatalog.STORY_SCENES.erase(AGAIN)
	DataCatalog.KEY_ITEMS.erase(TOKEN)
	DataCatalog.ROOM_ELEMENTS.erase(NPC)
	await _frames(2)
	AudioDirector.shutdown()
	await get_tree().create_timer(0.5).timeout
	print("DIALOGUE CHECK: ", "OK" if failures == 0 else "FAILED (%d)" % failures)
	get_tree().quit(0 if failures == 0 else 1)
