extends Node
## N3 multi-process test (tools/n3_test.ps1 on มังคุด): a real dedicated server (--server --server-test
## --data=user://n3test) + headless clients, one role per process:
##   a1  register "alice", create Alia + Alia2, delete Alia2 (two presses), play Alia: chat with Bobo,
##       see Bobo, only nearby monsters are known (interest), speed-hack / teleport commands refused
##       (position unchanged), pick up + kill + loot, then log out (state written to user://n3test_a1.json)
##   b1  register "bobby", create + play Bobo; the admin console (lines written into
##       <data>/control/console.txt, as start_server.ps1 does): admin on, announce, give, zeny, warp;
##       in-game admin chat (/give, /status); logs out on Whisperwood (state -> user://n3test_b1.json)
##   (the script kills the server process and starts it again on the same data)
##   b2  log in again: Bobo on Whisperwood with the admin's gifts; the console kicks Bobo
##   a2  wrong password refused, right one -> only Alia on the character select, Alia as she was;
##       console ban -> login refused, unban; 5 wrong passwords -> "Too many login attempts";
##       files: no plaintext password, backups, deleted/ keeps Alia2, logs (ANTICHEAT / ADMIN / economy)
## Every client prints N3TEST <role> PASS / FAIL; the script prints N3TEST PASS when all passed.

const NetProto = preload("res://scripts/net/net_proto.gd")
const WorldBuilder = preload("res://scripts/world_builder.gd")
const JsonStore = preload("res://scripts/server/json_store.gd")
const DATA := "user://n3test"
const TIMEOUT := 200.0

var role := ""
var port := 24660
var fails: Array = []
var msgs: Array = []
var evlog: Array = []
var _t := 0.0
var _done := false


func _ready() -> void:
	role = _arg("--n3test")
	if _arg("--n3port").is_valid_int():
		port = int(_arg("--n3port"))
	print("N3TEST %s start (port %d)" % [role, port])
	if role in ["a1", "a2", "b1", "b2"]:
		_client.call_deferred()


func _process(delta: float) -> void:
	_t += delta
	if _t > TIMEOUT and not _done and role != "server":
		check(false, "finished within %d s" % int(TIMEOUT))
		_finish()


func _arg(key: String) -> String:
	for a in OS.get_cmdline_user_args():
		if a.begins_with(key + "="):
			return a.substr(key.length() + 1)
	return ""


func check(ok: bool, what: String) -> void:
	print(("N3TEST %s   ok   " if ok else "N3TEST %s   FAIL ") % role + what)
	if not ok:
		fails.append(what)


func _finish() -> void:
	if _done:
		return
	_done = true
	print("N3TEST %s %s" % [role, "PASS" if fails.is_empty() else "FAIL (%d)" % fails.size()])
	var m = main()
	if m and m.get("net_client") and m.server:
		m.server.close()
	get_tree().quit(0 if fails.is_empty() else 1)


func main() -> Node:
	return get_tree().current_scene


func wait_for(cond: Callable, secs: float) -> bool:
	var t0 := _t
	while not cond.call():
		if _t - t0 > secs or _done:
			return cond.call()
		await get_tree().process_frame
	return true


func sleep(secs: float) -> void:
	await get_tree().create_timer(secs).timeout


## Admin console line, the way start_server.ps1 hands it over (the server reads the file twice a second).
func console(line: String) -> void:
	JsonStore.append_line(DATA.path_join("control/console.txt"), line)


func _logged_in() -> bool:
	var m = main()
	return m != null and m.get("net_client") and m.player != null and m.server != null and m.server.state == "playing"


func _lobby():
	var m = main()
	return m.lobby if m != null else null


func _on_select() -> bool:
	var l = _lobby()
	return l != null and l.chars_panel != null and l.chars_panel.visible


func _char_names() -> Array:
	var l = _lobby()
	if l == null:
		return []
	var out: Array = []
	for c in l.account_info.get("chars", []):
		out.append(str(c.get("name", "")))
	return out


func _status() -> String:
	var l = _lobby()
	return str(l.status.text) if l != null else ""


func _remote(n: String):
	for v in get_tree().get_nodes_in_group("remote_players"):
		if str(v.look.get("name", "")) == n and not v.is_queued_for_deletion():
			return v
	return null


func _bag_total() -> int:
	var n := 0
	for k in PlayerState.inventory:
		n += int(PlayerState.inventory[k])
	return n + PlayerState.gear.size()


func _state() -> Dictionary:
	return {"map": main().map_id, "lv": PlayerState.base_lv, "exp": PlayerState.base_exp, "zeny": PlayerState.zeny,
		"tonic": PlayerState.count("red_tonic"), "jelly": PlayerState.count("jelly"), "bag": _bag_total(), "name": PlayerState.char_name}


## Logs in (title), waits for the character select. Returns true on success.
func _login(user: String, pw: String, mode: String) -> bool:
	await wait_for(func(): return _lobby() != null and main().server == null, 15.0)
	_lobby().login_with("127.0.0.1", port, user, pw, mode)
	return await wait_for(func(): return _on_select(), 20.0)


## After a login failure: the title's error text (waits for it).
func _login_error(user: String, pw: String) -> String:
	await wait_for(func(): return _lobby() != null and main().server == null, 15.0)
	var l = _lobby()
	l.status.text = ""
	l.login_with("127.0.0.1", port, user, pw, "login")
	await wait_for(func(): return main().server == null and _lobby() != null and _lobby().status.text != "" and not _lobby().status.text.begins_with("Connecting"), 20.0)
	return _status()


func _play(n: String) -> bool:
	_lobby().play(n)
	var ok: bool = await wait_for(func(): return _logged_in(), 20.0)
	if ok:
		PlayerState.message.connect(func(t, _c): msgs.append(str(t)))
		main().server.event.connect(func(e): evlog.append(e))
		await wait_for(func(): return main().map_id != "" and main().player.global_position != Vector3.ZERO, 10.0)
		await sleep(0.5)
	return ok


func _leave() -> void:
	var m = main()
	var old_id: int = m.get_instance_id()
	m.leave_to_title("")
	await wait_for(func(): return main() != null and main().get_instance_id() != old_id and main().lobby != null, 10.0)
	await sleep(0.3)


func _saw(line: String) -> bool:
	return msgs.any(func(t): return str(t) == line or str(t).contains(line))


# ---------------------------------------------------------------- clients

func _client() -> void:
	match role:
		"a1":
			await _a1()
		"b1":
			await _b1()
		"b2":
			await _b2()
		"a2":
			await _a2()
	_finish()


func _a1() -> void:
	check(await _login("alice", "alicepw1", "register"), "register alice -> character select")
	check(_char_names().is_empty(), "a new account has no heroes")
	_lobby().create_char("Alia")
	check(await wait_for(func(): return _char_names().has("Alia"), 10.0), "create Alia")
	_lobby().create_char("Alia2")
	check(await wait_for(func(): return _char_names().has("Alia2"), 10.0), "create Alia2")
	_lobby().delete_char("Alia2")
	await sleep(0.5)
	check(_char_names().has("Alia2"), "one Delete press does not delete (asks to confirm)")
	_lobby().delete_char("Alia2")
	check(await wait_for(func(): return not _char_names().has("Alia2") and _char_names().size() == 1, 10.0), "second press deletes Alia2 (%s)" % str(_char_names()))
	check(await _play("Alia"), "play Alia")
	if not _logged_in():
		return
	var m = main()
	check(PlayerState.char_name == "Alia" and PlayerState.job == _start_job() and m.map_id == "aldoria_meadows", "Alia is a Novice in Aldoria")
	m.send({"t": "chat", "text": "hi from A"})
	check(await wait_for(func(): return _saw("Bobo: hi from B"), 40.0), "chat from Bobo arrives")
	check(await wait_for(func(): return _remote("Bobo") != null, 20.0), "sees Bobo's hero")
	# interest management: every monster view is near us
	await sleep(1.0)
	var far := 0
	var mons: Array = get_tree().get_nodes_in_group("monsters")
	for v in mons:
		if v.global_position.distance_to(m.player.global_position) > NetProto.INTEREST + NetProto.HYSTERESIS + 1.0:
			far += 1
	check(far == 0 and mons.size() < 61, "only nearby monsters are known (%d views, %d far)" % [mons.size(), far])
	check(m.server.snaps_got > 20, "quantised snapshots arrive (%d packets, %d lost, %d resyncs)" % [m.server.snaps_got, m.server.snaps_lost, m.server.resyncs])
	# speed hack / teleport: refused, the hero does not move
	var p0: Vector3 = m.player.global_position
	evlog.clear()
	m.send({"t": "move", "to": [p0.x + 30.0, p0.z], "speed": 80})
	m.send({"t": "teleport", "to": [p0.x + 30.0, p0.z]})
	await sleep(1.5)
	var refused: Array = evlog.filter(func(e): return str(e.get("t", "")) == "result" and not bool(e.get("ok", true)))
	check(refused.any(func(e): return str(e.get("error", "")).begins_with("anti-cheat")) and refused.any(func(e): return str(e.get("error", "")) == "unknown command"),
		"speed-hack + teleport commands refused (%s)" % str(refused.map(func(e): return e.get("error", ""))))
	check(m.player.global_position.distance_to(p0) < 1.0, "the hero did not move (%.2f m)" % m.player.global_position.distance_to(p0))
	# pick up, kill, loot
	var bag0 := _bag_total()
	check(await _pick_any("red_tonic", 20.0) and _bag_total() > bag0, "picked up a Red Tonic")
	var exp0: int = PlayerState.base_exp + PlayerState.base_lv * 1000000
	var mon = null
	var best := 1e9
	for v in get_tree().get_nodes_in_group("monsters"):
		var d: float = v.global_position.distance_to(m.player.global_position)
		if not v.dead and str(v.mid) == "pudding_slime" and d < best:
			best = d
			mon = v
	if mon == null:
		# none in view yet: walk toward the meadow
		m.send({"t": "move", "to": [m.player.global_position.x + 25.0, m.player.global_position.z]})
		await sleep(5.0)
		for v in get_tree().get_nodes_in_group("monsters"):
			var d2: float = v.global_position.distance_to(m.player.global_position)
			if not v.dead and d2 < best:
				best = d2
				mon = v
	check(mon != null, "a monster in view (%.0f m)" % best)
	if mon != null:
		var mid: int = mon.id
		var t_end := _t + 90.0
		while not _killed(m) and _t < t_end:
			if m.view(mid) != null:
				m.send({"t": "attack", "target": mid, "keep": true})
			await sleep(0.5)
	check(_killed(m), "killed a monster on the dedicated server")
	check(await wait_for(func(): return PlayerState.base_exp + PlayerState.base_lv * 1000000 > exp0, 5.0), "EXP arrived")
	var bag1 := _bag_total()
	check(await _pick_any("", 25.0) and _bag_total() > bag1, "looted the drop")
	m.send({"t": "chat", "text": "A done"})
	check(await wait_for(func(): return _saw("Bobo: B done"), 90.0), "Bobo finished the admin checks")
	await sleep(1.0)
	var st := _state()
	JsonStore.save("user://n3test_a1.json", st, 0)
	print("N3TEST a1 state ", st)
	await _leave()
	check(main().lobby != null, "logged out to the title")


func _killed(m) -> bool:
	for e in evlog:
		if str(e.get("t", "")) == "died" and str(e.get("kind", "")) == "mob" and int(e.get("killer", 0)) == m.my_id:
			return true
	return false


func _pick_any(iid: String, secs: float) -> bool:
	var m = main()
	var t_end := _t + secs
	var total0 := _bag_total()
	while _t < t_end:
		var best = null
		var bd := 1e9
		for l in get_tree().get_nodes_in_group("loot"):
			if l.is_queued_for_deletion() or (iid != "" and l.item_id != iid):
				continue
			var d: float = l.global_position.distance_to(m.player.global_position)
			if d < bd:
				bd = d
				best = l
		if best != null:
			m.send({"t": "pickup", "id": best.id})
		await sleep(0.6)
		if _bag_total() > total0:
			return true
	return false


func _b1() -> void:
	await sleep(1.0)
	check(await _login("bobby", "bobbypw1", "register"), "register bobby -> character select")
	# Alice's Alia exists (same PC: the server's data folder) -> that name is taken
	await wait_for(func(): return JsonStore.exists(DATA + "/accounts/alice/chars/Alia.json"), 20.0)
	_lobby().create_char("Alia")
	await sleep(1.0)
	check(not _char_names().has("Alia") and _lobby().chars_status.text.contains("taken"), "a name used by another account is refused")
	_lobby().create_char("Bobo")
	check(await wait_for(func(): return _char_names().has("Bobo"), 10.0), "create Bobo")
	check(await _play("Bobo"), "play Bobo")
	if not _logged_in():
		return
	var m = main()
	m.send({"t": "chat", "text": "hi from B"})
	check(await wait_for(func(): return _saw("Alia: hi from A"), 40.0), "chat from Alia arrives")
	check(await wait_for(func(): return _remote("Alia") != null, 20.0), "sees Alia's hero")
	var tonic0: int = PlayerState.count("red_tonic")
	var zeny0: int = PlayerState.zeny
	console("admin bobby on")
	console("announce Admin says hi")
	console("give Bobo red_tonic 7")
	console("zeny Bobo 1234")
	check(await wait_for(func(): return _saw("[Server] Admin says hi"), 15.0), "console announce reaches the players")
	check(await wait_for(func(): return PlayerState.count("red_tonic") >= tonic0 + 7 and PlayerState.zeny == zeny0 + 1234, 10.0),
		"console give + zeny (%d tonics, %d zeny)" % [PlayerState.count("red_tonic"), PlayerState.zeny])
	var jelly0: int = PlayerState.count("jelly")
	m.send({"t": "chat", "text": "/give Bobo jelly 2"})
	check(await wait_for(func(): return PlayerState.count("jelly") == jelly0 + 2, 10.0), "in-game admin /give (after 'admin bobby on')")
	m.send({"t": "chat", "text": "/status"})
	check(await wait_for(func(): return _saw("player(s)"), 10.0), "in-game admin /status")
	check(await wait_for(func(): return _saw("Alia: A done"), 150.0), "Alia finished")
	m.send({"t": "chat", "text": "B done"})
	await sleep(2.0)
	console("warp Bobo whisperwood_forest 10 10")
	check(await wait_for(func(): return m.map_id == "whisperwood_forest" and not m.traveling, 20.0), "console warp to Whisperwood")
	await sleep(1.5)
	var st := _state()
	JsonStore.save("user://n3test_b1.json", st, 0)
	print("N3TEST b1 state ", st)
	await _leave()


func _b2() -> void:
	var want := JsonStore.read("user://n3test_b1.json")
	check(not want.is_empty(), "b1 state file")
	check(await _login("bobby", "bobbypw1", "login"), "bobby logs in after the server restart")
	check(_char_names() == ["Bobo"] and bool(_lobby().account_info.get("admin", false)), "Bobo on the select, admin flag kept")
	check(await _play("Bobo"), "play Bobo")
	if not _logged_in():
		return
	var st := _state()
	check(st["map"] == want.get("map") and int(st["tonic"]) == int(want.get("tonic", -1)) and int(st["zeny"]) == int(want.get("zeny", -1))
		and int(st["jelly"]) == int(want.get("jelly", -1)) and int(st["bag"]) == int(want.get("bag", -1)), "Bobo as he was (%s vs %s)" % [str(st), str(want)])
	console("kick Bobo testing kick")
	check(await wait_for(func(): return main().lobby != null and not _logged_in() and _status().contains("testing kick"), 20.0), "console kick -> back at the title (%s)" % _status())


func _a2() -> void:
	var want := JsonStore.read("user://n3test_a1.json")
	check(not want.is_empty(), "a1 state file")
	var e1 := await _login_error("alice", "wrongpw9")
	check(e1.contains("Wrong password"), "wrong password refused (%s)" % e1)
	check(await _login("alice", "alicepw1", "login"), "alice logs in after the server restart")
	check(_char_names() == ["Alia"], "only Alia on the select (Alia2 stays deleted): %s" % str(_char_names()))
	check(await _play("Alia"), "play Alia")
	if _logged_in():
		var st := _state()
		check(st["map"] == want.get("map") and int(st["lv"]) == int(want.get("lv", -1)) and int(st["exp"]) == int(want.get("exp", -1))
			and int(st["zeny"]) == int(want.get("zeny", -1)) and int(st["bag"]) == int(want.get("bag", -1)), "Alia as she was (%s vs %s)" % [str(st), str(want)])
		await _leave()
	console("ban alice cheating test")
	await sleep(1.5)
	var e2 := await _login_error("alice", "alicepw1")
	check(e2.contains("banned"), "banned account refused (%s)" % e2)
	console("unban alice")
	await sleep(1.5)
	check(await _login("alice", "alicepw1", "login"), "unbanned: login works again")
	main().lobby.chosen.emit("back", {})
	await sleep(1.0)
	var got_lock := ""
	for i in 7:
		var e3 := await _login_error("bobby", "nope%d" % i)
		if e3.contains("Too many"):
			got_lock = e3
			break
	check(got_lock != "", "bad password rate limit: locked after repeated failures (%s)" % got_lock)
	# files on the server's data folder (same PC)
	var acc := FileAccess.get_file_as_string(DATA + "/accounts/alice/account.json")
	check(acc.contains("\"hash\"") and not acc.contains("alicepw1"), "account file: hash, no plaintext password")
	check(FileAccess.file_exists(DATA + "/accounts/alice/chars/Alia.json.bak1") and FileAccess.file_exists(DATA + "/accounts/bobby/chars/Bobo.json"),
		"character files + rotating backups")
	check(DirAccess.dir_exists_absolute(DATA + "/deleted") and DirAccess.get_files_at(DATA + "/deleted").size() == 1, "deleted/ keeps Alia2")
	var logs := ""
	for fn in DirAccess.get_files_at(DATA + "/logs"):
		logs += FileAccess.get_file_as_string(DATA + "/logs/" + fn)
	check(logs.contains("ANTICHEAT") and logs.contains("ADMIN console: give Bobo red_tonic 7") and logs.contains("login failed") and not logs.contains("alicepw1"),
		"server log: anti-cheat, admin commands, failed logins, no passwords")
	var eco := ""
	for fn in DirAccess.get_files_at(DATA + "/events"):
		eco += FileAccess.get_file_as_string(DATA + "/events/" + fn)
	check(eco.contains("\"cause\":\"admin\"") and eco.contains("\"cause\":\"play\""), "economy log: admin gifts + play summaries")


## Class of a brand-new character: Novice, or the default Combat v2 class when the flag is on.
static func _start_job() -> String:
	if bool(GameData.config.get("combat_v2", false)):
		return "defend_counter"
	return "novice"
