extends RefCounted
## Personal helper (server/life_helper.gd, life/life_helper_ui.gd): selftest --only=helper (also in --only=life and the
## full run through life_test.gd). Data (staff, Tilly at home, shop without retired tonics), visited maps, summon through
## the staff (window + sprite), cooldown / fight / dungeon refusals, blessing (% buffs), shared storage (persists in its
## file, shared across characters), shop buy / sell, travel to visited maps only, home (needs the deed), the Homestead
## Tilly without a summon, walking away, the window tabs + real buttons.

const SelfTest = preload("res://scripts/selftest.gd")
const CharacterState = preload("res://scripts/server/character_state.gd")
const Life = preload("res://scripts/server/life.gd")
const LifeHelper = preload("res://scripts/server/life_helper.gd")

const DIR := "user://selftest_helper_storage"


static func check(ok: bool, what: String) -> void:
	SelfTest.check(ok, what)


static func frames(main: Node, n: int) -> void:
	await SelfTest.frames(main, n)


static func wait_until(main: Node, cond: Callable, max_s: float) -> bool:
	var t0 := Time.get_ticks_msec()
	while Time.get_ticks_msec() - t0 < int(max_s * 1000.0):
		if cond.call():
			return true
		await main.get_tree().physics_frame
	return cond.call()


static func run(main: Node) -> void:
	print("SELFTEST helper")
	var c = PlayerState.ch
	var pe = main.me()
	var lu = main.hud.life_ui
	var cfg := Life.helper_cfg()
	var staff := str(cfg.get("staff", ""))
	LifeHelper.test_dir = DIR
	LifeHelper._stores.clear()
	var sp0 := LifeHelper.storage_path(pe)
	if FileAccess.file_exists(sp0):
		DirAccess.remove_absolute(sp0)
	LifeHelper.cooldowns.clear()
	LifeHelper.sessions.clear()
	# data
	check(not GameData.item(staff).is_empty() and bool(GameData.item(staff).get("helper", false)) and str(GameData.item(staff).get("name_th", "")) != "",
		"helper: the Hearthbell Staff item (EN + TH)")
	var home_npc := {}
	for n in GameData.npcs:
		if str(n.get("life", "")) == "helper" and str(n.get("map", "")) == "homestead":
			home_npc = n
	check(str(home_npc.get("map", "")) == "homestead" and str(home_npc.get("name_th", "")) != "", "helper: Tilly stands in the Homestead")
	var sl := LifeHelper.shop_list()
	check(sl.size() >= 6 and "red_tonic" in sl and "fly_wing" in sl and "arrow" in sl and not ("blue_tonic" in sl),
		"helper: shop sells potions (no retired tonics), wings, arrows (%d items)" % sl.size())
	check(load("res://scripts/buffs.gd").INFO.has("hearth_blessing") and load("res://scripts/buffs.gd").INFO.has("hearth_swift"), "helper: the blessings have HUD labels")
	# visited maps
	await frames(main, 2)
	check(bool(c.life.get("visited", {}).get(pe.map.map_id, false)), "helper: the current map is remembered as visited")
	# not here yet
	var r: Dictionary = main.send({"t": "helper_buff"})
	check(not bool(r.get("ok", true)), "helper: no helper, no service")
	# summon
	c.add_item(staff, 1)
	c.combat_until = 0.0
	main.teleport_player(Vector3(-40, 0, 20))
	await frames(main, 2)
	main.send({"t": "item_use", "id": staff})
	var came: bool = await wait_until(main, func(): return LifeHelper.sessions.has(main.me().get_instance_id()), 2.0)
	check(came and c.count(staff) == 1, "helper: ringing the staff calls the helper (staff kept)")
	await frames(main, 4)
	var v = lu._view()
	check(v != null and v.helper_node != null and is_instance_valid(v.helper_node), "helper: the sprite appears next to you")
	check(main.hud.is_open("helper"), "helper: her window opens")
	check(LifeHelper.near(pe), "helper: in reach")
	check(LifeHelper.can_summon(pe).contains("s)"), "helper: short cooldown between calls (%s)" % LifeHelper.can_summon(pe))
	LifeHelper.cooldowns.clear()
	c.combat_until = Life.now_s() + 5.0
	check(LifeHelper.can_summon(pe) != "", "helper: not during a fight")
	c.combat_until = 0.0
	# blessing
	c.hp = 1
	var aspd0: int = c.equip_bonus("aspd_rate")
	r = main.send({"t": "helper_buff"})
	check(bool(r.get("ok", false)) and c.hp == c.max_hp() and c.buffs.has("hearth_blessing") and c.buffs.has("hearth_swift"), "helper: blessing = full heal + both buffs")
	check(Life.bonus(c, "str") >= 1 and c.equip_bonus("aspd_rate") - aspd0 == int(cfg["buff_pct"]["hearth_swift"]["aspd_rate"]), "helper: blessings are %% buffs (+%d STR, +%d%% attack speed)" % [Life.bonus(c, "str"), c.equip_bonus("aspd_rate") - aspd0])
	# storage
	c.add_item("red_tonic", 5)
	var n0: int = c.count("red_tonic")
	r = main.send({"t": "helper_store", "id": "red_tonic", "n": 3})
	check(bool(r.get("ok", false)) and c.count("red_tonic") == n0 - 3 and int(LifeHelper.storage(pe).get("red_tonic", 0)) == 3, "helper: store 3 Red Tonics")
	check(FileAccess.file_exists(LifeHelper.storage_path(pe)), "helper: storage saved to its file")
	LifeHelper._stores.clear()
	check(int(LifeHelper.storage(pe).get("red_tonic", 0)) == 3, "helper: storage survives a reload")
	var other := CharacterState.new()
	other.new_character("Alt")
	check(LifeHelper.storage_path(pe).get_file() == "local.json", "helper: one storage for every character (account 'local')")
	r = main.send({"t": "helper_store", "id": staff, "n": 1})
	check(not bool(r.get("ok", true)) and c.count(staff) == 1, "helper: the staff stays in the bag")
	r = main.send({"t": "helper_take", "id": "red_tonic", "n": 2})
	check(bool(r.get("ok", false)) and c.count("red_tonic") == n0 - 1 and int(LifeHelper.storage(pe).get("red_tonic", 0)) == 1, "helper: take 2 back")
	r = main.send({"t": "helper_take", "id": "red_tonic", "n": 9})
	check(bool(r.get("ok", false)) and not LifeHelper.storage(pe).has("red_tonic"), "helper: take more than stored = all that is there")
	# shop
	c.zeny = 100000
	var z0: int = c.zeny
	r = main.send({"t": "helper_buy", "id": "fly_wing", "n": 2})
	check(bool(r.get("ok", false)) and c.zeny == z0 - 2 * c.buy_price("fly_wing"), "helper: buy 2 Fly Wings")
	r = main.send({"t": "helper_buy", "id": "blue_tonic", "n": 1})
	check(not bool(r.get("ok", true)), "helper: retired tonics are not sold")
	z0 = c.zeny
	r = main.send({"t": "helper_sell", "id": "fly_wing", "n": 1})
	check(bool(r.get("ok", false)) and c.zeny == z0 + c.sell_price("fly_wing"), "helper: sell one back")
	# window: tabs + real buttons
	lu.helper_ui.open("shop")
	await frames(main, 2)
	var body: VBoxContainer = main.hud.bodies["helper"]
	var b1: Button = body.find_child("Buy1_red_tonic", true, false)
	var t0: int = c.count("red_tonic")
	if b1:
		b1.pressed.emit()
	check(b1 != null and c.count("red_tonic") == t0 + 1, "helper: the Shop tab's Buy button buys")
	lu.helper_ui.open("store")
	await frames(main, 2)
	var sb: Button = body.find_child("Store1_red_tonic", true, false)
	var had_sb := sb != null
	if sb:
		sb.pressed.emit()
	await frames(main, 2)
	check(had_sb and int(LifeHelper.storage(pe).get("red_tonic", 0)) == 1 and body.find_child("Take1_red_tonic", true, false) != null,
		"helper: the Storage tab stores + lists (button %s, stored %d, listed %s)" % [had_sb, int(LifeHelper.storage(pe).get("red_tonic", 0)), body.find_child("Take1_red_tonic", true, false) != null])
	lu.helper_ui.open("bless")
	await frames(main, 2)
	check(body.find_child("HelperBless", true, false) != null, "helper: the Blessing tab")
	# travel
	var wl: Array = LifeHelper.warp_list(c)
	check(GameData.start_map() in wl and not ("homestead" in wl), "helper: travel list = visited maps (%d), home is separate" % wl.size())
	var never := ""
	for m in GameData.map_list:
		if not (str(m["id"]) in wl) and str(m.get("unlock_quest", "")) == "" and str(m["id"]) != "homestead":
			never = str(m["id"])
			break
	r = main.send({"t": "helper_warp", "map": never})
	check(never != "" and not bool(r.get("ok", true)), "helper: no travel to a map never visited (%s)" % never)
	lu.helper_ui.open("travel")
	await frames(main, 2)
	check(body.find_child("HelperHome", true, false) != null and body.find_child("Warp_" + GameData.start_map(), true, false) != null, "helper: the Travel tab lists places + Home")
	var dest := "whisperwood_forest"
	c.life["visited"][dest] = true
	r = main.send({"t": "helper_warp", "map": dest})
	var there: bool = await wait_until(main, func(): return main.me().map != null and main.me().map.map_id == dest and not main.me().traveling, 20.0)
	check(bool(r.get("ok", false)) and there, "helper: travel to a visited map")
	await frames(main, 10)
	check(not LifeHelper.sessions.has(main.me().get_instance_id()), "helper: she goes home when you travel")
	# dungeon
	main.load_map("hollowroot_caverns")
	await frames(main, 20)
	LifeHelper.cooldowns.clear()
	check(LifeHelper.can_summon(main.me()).contains("dungeon"), "helper: the bell does not ring in dungeons")
	# home: needs the deed
	main.load_map(dest)
	await frames(main, 20)
	main.send({"t": "item_use", "id": staff})
	await wait_until(main, func(): return LifeHelper.sessions.has(main.me().get_instance_id()), 2.0)
	c.quests.done.erase("life_home_1")
	r = main.send({"t": "helper_home"})
	check(not bool(r.get("ok", true)), "helper: no Homestead, no trip home")
	c.quests.done.append("life_home_1")
	# Housing v2: Home = the own house in its Hearthvale lane (a test community store)
	var Housing = load("res://scripts/server/housing.gd")
	var house0: Dictionary = c.life.get("house", {}).duplicate()
	Housing.test_dir = "user://selftest_helper_housing"
	Housing.store = {}
	Housing._path = ""
	c.life["house"] = {"comm": "", "plot": -1, "moved": 0, "comfort": 0, "tier": 0, "migrated": 1}
	r = main.send({"t": "helper_home"})
	check(not bool(r.get("ok", true)), "helper: no address yet, no trip home")
	var lane := str(Housing.maps()[0])
	r = main.send({"t": "house_claim", "comm": lane, "plot": 3})
	check(bool(r.get("ok", false)), "helper: (claim a house for the test) %s" % str(r.get("err", "")))
	await wait_until(main, func(): return main.me().map != null and main.me().map.map_id == lane and not main.me().traveling, 20.0)
	await frames(main, 10)
	main.load_map(dest)
	await frames(main, 20)
	LifeHelper.cooldowns.clear()
	main.send({"t": "item_use", "id": staff})
	await wait_until(main, func(): return LifeHelper.sessions.has(main.me().get_instance_id()), 2.0)
	r = main.send({"t": "helper_home"})
	var home: bool = await wait_until(main, func(): return main.me().map != null and main.me().map.map_id == lane and not main.me().traveling, 20.0)
	check(bool(r.get("ok", false)) and home, "helper: Home = your house in Hearthvale")
	await frames(main, 20)
	# Tilly on the lane plaza works without a summon
	LifeHelper.sessions.clear()
	var tn = main.me().map.npc_by_def("hv_tilly_1")
	home_npc = {}
	for n in GameData.npcs:
		if str(n.get("id", "")) == "hv_tilly_1":
			home_npc = n
	check(tn != null, "helper: Tilly is on the lane plaza")
	if tn != null:
		main.teleport_player(tn.pos + Vector3(1.5, 0, 0))
		await frames(main, 2)
		r = main.send({"t": "helper_open"})
		check(bool(r.get("ok", false)) and (r.get("maps", []) as Array).size() >= 2, "helper: the lane's Tilly serves you")
		main.hud.windows["helper"].visible = false
		lu.open_npc(home_npc)
		await frames(main, 2)
		check(main.hud.is_open("helper"), "helper: clicking her opens the window")
		main.teleport_player(tn.pos + Vector3(14, 0, 0))
		await frames(main, 2)
		r = main.send({"t": "helper_buff"})
		check(not bool(r.get("ok", true)), "helper: walk away and she cannot help")
	main.hud.windows["helper"].visible = false
	c.quests.done.erase("life_home_1")
	var hsp: String = Housing._path
	Housing.store = {}
	Housing._path = ""
	Housing.client.clear()
	if FileAccess.file_exists(hsp):
		DirAccess.remove_absolute(hsp)
	Housing.test_dir = ""
	c.life["house"] = house0
	main.load_map(GameData.start_map())
	await frames(main, 20)
	c.buffs.remove("hearth_blessing")
	c.buffs.remove("hearth_swift")
	LifeHelper.sessions.clear()
	LifeHelper.cooldowns.clear()
	LifeHelper.pending.clear()
	var spf := LifeHelper.storage_path(main.me())
	LifeHelper._stores.clear()
	if FileAccess.file_exists(spf):
		DirAccess.remove_absolute(spf)
	LifeHelper.test_dir = ""
