extends CanvasLayer
## Title screen / lobby. Screens (one at a time, docs/STYLE_GUIDE.md section 8 parchment look):
##   title      - "Pandolia" logo, big Play Solo, Play Online, Host a game, Quit
##   login      - account + password (masked, Show toggle) + server IP / port, remember checkbox
##   register   - the same card + password confirmation
##   charselect - up to 3 hero cards (rotating 3D job model, level, job, map, last played) + empty
##                slots (Create), Delete asks in a confirm dialog
##   create     - name a new Novice (rotating preview)
## Behind it: main.gd's live town scenery with a slowly orbiting camera (a sky gradient until it is
## built, and in headless runs). Keyboard: Tab / Shift+Tab walk the fields, Enter goes to the next
## empty field or submits, Esc goes back.
## Emits `chosen(mode, opts)` (solo / host / join / play / create / delete / back); main.gd does the
## rest and calls set_status / show_error / show_chars / show_notice. The last account / IP / port are
## remembered in user://net.json when "Remember" is ticked (never the password).
## Screenshots: main.gd --shotlobby=<screen> (shot_setup() fills fake data, no network).

const NetProto = preload("res://scripts/net/net_proto.gd")
const ModelLoader = preload("res://scripts/model_loader.gd")
const CharStore = preload("res://scripts/server/char_store.gd")

signal chosen(mode: String, opts: Dictionary)

const PARCH := Color("#f2e5c4")
const PARCH_LIGHT := Color("#fbf3de")
const PARCH_DARK := Color("#e6d2a8")
const FRAME := Color("#97764a")
const PLAQUE := Color("#4b3321")
const CREAM := Color("#fff4d8")
const INK := Color("#3b2a1a")
const INK_SOFT := Color("#7a6248")
const BAD := Color("#b3261e")
const GOOD := Color("#2f7d2a")
const BTN_GREEN := Color("#58b84c")
const BTN_GOLD := Color("#e0a030")
const BTN_BLUE := Color("#4c8fd0")
const BTN_GREY := Color("#9c8b6e")
const BTN_RED := Color("#c4543f")
const FOCUS := Color("#ffd35c")
## UI scale: the game's canvas is 1600 x 900 logical (stretch canvas_items), so a 1028 x 749 window
## shows it at 0.64 - sizes below are multiplied by K to stay readable there.
const K := 1.3
const SCREENS := ["title", "login", "register", "charselect", "create"]
## Screens main.gd --shotlobby can show (shot_setup): the five above + dialogs / error states.
const SHOT_SCREENS := ["title", "login", "register", "charselect", "create", "delete", "wrongpass", "lockout",
	"unreachable", "version", "connecting"]

## Where the last account / server are remembered (tests point it elsewhere).
var save_path := "user://net.json"
## The screen on show (one of SCREENS).
var screen := "title"

var name_edit: LineEdit
var pass_edit: LineEdit
var pass2_edit: LineEdit
var ip_edit: LineEdit
var port_edit: LineEdit
var show_pass: Button
var remember: CheckBox
## Login / register messages (English) + a Thai line under it.
var status: Label
var status_th: Label
## Messages on the title card (host start, a session that ended).
var title_msg: Label
var title_msg_th: Label
var buttons: Array = []
var title_panel: PanelContainer
var login_panel: PanelContainer
var chars_panel: PanelContainer
var create_panel: PanelContainer
var solo_btn: Button
var online_btn: Button
var host_btn: Button
var primary_btn: Button
var switch_btn: Button
var back_btn: Button
var logout_btn: Button
var acc_title: Label
var acc_title_th: Label
var _pass2_label: Control
var chars_box: HBoxContainer
var chars_title: Label
var chars_user: Label
var chars_slots: Label
var chars_status: Label
var chars_status_th: Label
var create_name: LineEdit
var create_msg: Label
var create_btn: Button
var _create_stage: Control
## Delete confirmation dialog.
var dialog: Control
var dialog_text: Label
var dialog_ok: Button
var dialog_cancel: Button
## Account info last shown ({user, chars, admin, max_chars, note}).
var account_info: Dictionary = {}
## Solo card line (the local hero), set in _ready / shot_setup.
var solo_line: Label
var _del_armed := ""
var _del_t := 0.0
## Login lockout countdown: msec clock when it ends, the message with %d for the seconds.
var _lock_until := 0
var _lock_fmt := ""
var _theme := Theme.new()
var _bold: SystemFont
var _fallback_bg: ColorRect
var _logo: Label
var _logo_box: VBoxContainer
var _tagline: Label
var _spin: Array = []
var _headless := false
## Short canvas (1600 x 900): smaller logo + model stages; tiny (< 820): no logo on the tall screens.
var _compact := false
var _tiny := false


func _ready() -> void:
	layer = 60
	_headless = DisplayServer.get_name() == "headless"
	_make_theme()
	var root := Control.new()
	root.set_anchors_preset(Control.PRESET_FULL_RECT)
	root.theme = _theme
	add_child(root)
	# sky gradient until main.gd's town backdrop is built (and in headless runs)
	_fallback_bg = ColorRect.new()
	_fallback_bg.set_anchors_preset(Control.PRESET_FULL_RECT)
	_fallback_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
	_fallback_bg.color = Color(0.6, 0.78, 0.98)
	if not _headless:
		_fallback_bg.material = _shader_mat("shader_type canvas_item;\nvoid fragment() {\n\tvec3 top = vec3(0.36, 0.62, 0.98);\n\tvec3 mid = vec3(0.86, 0.92, 1.0);\n\tvec3 low = vec3(0.55, 0.76, 0.45);\n\tfloat y = UV.y;\n\tvec3 c = y < 0.62 ? mix(top, mid, y / 0.62) : mix(mid, low, smoothstep(0.62, 0.72, y));\n\tCOLOR = vec4(c, 1.0);\n}\n")
	root.add_child(_fallback_bg)
	# soft dark vignette + darker top / bottom bands: the logo and the cards read over any scenery
	var shade := ColorRect.new()
	shade.set_anchors_preset(Control.PRESET_FULL_RECT)
	shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
	shade.color = Color(0, 0, 0, 0)
	if not _headless:
		shade.material = _shader_mat("shader_type canvas_item;\nvoid fragment() {\n\tvec2 d = UV - vec2(0.5, 0.48);\n\tfloat v = smoothstep(0.35, 0.85, length(d * vec2(1.0, 1.25)));\n\tfloat band = 0.28 * (1.0 - smoothstep(0.0, 0.3, UV.y)) + 0.22 * smoothstep(0.82, 1.0, UV.y);\n\tCOLOR = vec4(0.10, 0.06, 0.14, clamp(0.10 + 0.45 * v + band, 0.0, 0.7));\n}\n")
	root.add_child(shade)
	var margin := MarginContainer.new()
	margin.set_anchors_preset(Control.PRESET_FULL_RECT)
	for side in ["left", "right"]:
		margin.add_theme_constant_override("margin_" + side, 16)
	margin.add_theme_constant_override("margin_top", 10)
	margin.add_theme_constant_override("margin_bottom", 8)
	root.add_child(margin)
	var outer := VBoxContainer.new()
	outer.add_theme_constant_override("separation", 4)
	margin.add_child(outer)
	_make_logo(outer)
	var center := CenterContainer.new()
	center.size_flags_vertical = Control.SIZE_EXPAND_FILL
	outer.add_child(center)
	_make_title_panel(center)
	_make_account_panel(center)
	_make_chars_panel(center)
	_make_create_panel(center)
	_make_footer(outer)
	_make_dialog(root)
	var cfg := _load()
	name_edit.text = str(cfg.get("name", ""))
	ip_edit.text = str(cfg.get("ip", ""))
	port_edit.text = str(cfg.get("port", NetProto.DEFAULT_PORT))
	# the patch launcher passes the current server address: --join=host:port wins over remembered values
	for a in OS.get_cmdline_user_args():
		if a.begins_with("--join="):
			var ja: Array = NetProto.parse_address(a.substr(7))
			ip_edit.text = str(ja[0])
			port_edit.text = str(int(ja[1]))
			print("LOBBY server from --join: %s:%d" % [ip_edit.text, int(ja[1])])
	remember.button_pressed = bool(cfg.get("remember", true))
	solo_line.text = solo_summary()
	get_viewport().size_changed.connect(_on_resize)
	_on_resize()
	_goto("title")


func _process(delta: float) -> void:
	for l in [status, status_th, title_msg, title_msg_th, chars_status_th, create_msg]:
		(l as Label).visible = (l as Label).text != ""
	for p in _spin:
		if is_instance_valid(p):
			(p as Node3D).rotation.y += delta * 0.6
	if _lock_until > 0:
		var left := int(ceil((_lock_until - Time.get_ticks_msec()) / 1000.0))
		if left <= 0:
			_lock_until = 0
			status.text = "You can try again now."
			status_th.text = "ลองเข้าสู่ระบบใหม่ได้แล้ว"
			status.add_theme_color_override("font_color", GOOD)
			primary_btn.disabled = false
		else:
			status.text = _lock_fmt % left
			primary_btn.disabled = true


## Esc = back (dialog -> cancel, create -> heroes, heroes -> log out, login -> title).
func _input(event: InputEvent) -> void:
	if not (event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE):
		return
	get_viewport().set_input_as_handled()
	go_back()


func go_back() -> void:
	if dialog.visible:
		_cancel_delete()
	elif screen == "create":
		_goto("charselect")
	elif screen == "charselect":
		_on_back()
	elif screen in ["login", "register"] and not _is_busy():
		_goto("title")


# ---------------------------------------------------------------- building the screens

func _make_logo(outer: VBoxContainer) -> void:
	_logo_box = VBoxContainer.new()
	_logo_box.add_theme_constant_override("separation", 0)
	_logo_box.alignment = BoxContainer.ALIGNMENT_CENTER
	outer.add_child(_logo_box)
	_logo = Label.new()
	_logo.text = "Pandolia"
	_logo.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	var lf := SystemFont.new()
	lf.font_names = PackedStringArray(["Palatino Linotype", "Book Antiqua", "Georgia", "Constantia", "Times New Roman", "Serif"])
	lf.font_weight = 700
	_logo.add_theme_font_override("font", lf)
	_logo.add_theme_font_size_override("font_size", 92)
	_logo.add_theme_color_override("font_color", Color(1, 1, 1))
	_logo.add_theme_color_override("font_outline_color", Color("#3a2210"))
	_logo.add_theme_constant_override("outline_size", 14)
	_logo.add_theme_color_override("font_shadow_color", Color(0.08, 0.04, 0.02, 0.55))
	_logo.add_theme_constant_override("shadow_offset_x", 0)
	_logo.add_theme_constant_override("shadow_offset_y", 6)
	_logo.add_theme_constant_override("shadow_outline_size", 18)
	if not _headless:
		# gold gradient on the letters only (outline / shadow keep their colour: their vertex colour is not white)
		var m := _shader_mat("shader_type canvas_item;\nuniform float y0 = 20.0;\nuniform float y1 = 100.0;\nvarying float ly;\nvarying vec4 vc;\nvoid vertex() { ly = VERTEX.y; vc = COLOR; }\nvoid fragment() {\n\tvec4 t = texture(TEXTURE, UV);\n\tif (vc.r > 0.98 && vc.g > 0.98 && vc.b > 0.98) {\n\t\tfloat k = clamp((ly - y0) / (y1 - y0), 0.0, 1.0);\n\t\tvec3 top = vec3(1.0, 0.97, 0.78);\n\t\tvec3 midc = vec3(1.0, 0.82, 0.36);\n\t\tvec3 bot = vec3(0.86, 0.52, 0.16);\n\t\tvec3 c = k < 0.5 ? mix(top, midc, k * 2.0) : mix(midc, bot, (k - 0.5) * 2.0);\n\t\tCOLOR = vec4(c, t.a * vc.a);\n\t}\n}\n")
		_logo.material = m
	_logo_box.add_child(_logo)
	var orn := Control.new()
	orn.custom_minimum_size = Vector2(520, 16)
	orn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
	orn.mouse_filter = Control.MOUSE_FILTER_IGNORE
	orn.draw.connect(func():
		var w := orn.size.x
		var y := orn.size.y * 0.5
		var gold := Color("#f3c75a")
		var dark := Color("#3a2210")
		for s in [-1.0, 1.0]:
			var a := Vector2(w * 0.5 + s * 16.0, y)
			var b := Vector2(w * 0.5 + s * (w * 0.5 - 6.0), y)
			orn.draw_line(a, b, dark, 5.0, true)
			orn.draw_line(a, b, gold, 2.0, true)
		var c := Vector2(w * 0.5, y)
		var dia := PackedVector2Array([c + Vector2(0, -7), c + Vector2(8, 0), c + Vector2(0, 7), c + Vector2(-8, 0)])
		orn.draw_colored_polygon(dia, gold)
		orn.draw_polyline(dia + PackedVector2Array([dia[0]]), dark, 2.0, true))
	_logo_box.add_child(orn)
	_tagline = _outlined("Classic fantasy RPG  ·  เกมผจญภัยแฟนตาซี", 17)
	_logo_box.add_child(_tagline)


func _make_title_panel(center: Control) -> void:
	title_panel = _card(center)
	var v := _vbox(title_panel, 10)
	v.add_child(_plaque("Welcome, adventurer", "ยินดีต้อนรับ นักผจญภัย"))
	solo_btn = _big_button("Play Solo", "เล่นคนเดียว", _on_solo, BTN_GREEN, Vector2(380, 84), 26)
	solo_line = solo_btn.get_node("Box/Extra")
	v.add_child(solo_btn)
	online_btn = _big_button("Play Online", "เล่นออนไลน์  ·  Login / Register", func(): _goto("login"), BTN_BLUE, Vector2(380, 60), 21)
	v.add_child(online_btn)
	var r := HBoxContainer.new()
	r.add_theme_constant_override("separation", 10)
	v.add_child(r)
	host_btn = _big_button("Host a game", "เปิดห้องให้เพื่อน", _on_host, BTN_GOLD, Vector2(185, 54), 18)
	r.add_child(host_btn)
	r.add_child(_big_button("Quit", "ออกจากเกม", func(): get_tree().quit(), BTN_GREY, Vector2(185, 54), 18))
	title_msg = _wrap_label("", 14, INK_SOFT, 380)
	v.add_child(title_msg)
	title_msg_th = _wrap_label("", 13, INK_SOFT, 380)
	v.add_child(title_msg_th)
	var hint := _wrap_label(load("res://scripts/ui/msg_th.gd").tr_msg("Host: friends join with your IP (LAN / Tailscale), port %d." % NetProto.DEFAULT_PORT), 13, INK_SOFT, 380)
	v.add_child(hint)


func _make_account_panel(center: Control) -> void:
	login_panel = _card(center)
	var v := _vbox(login_panel, 8)
	var pl := _plaque("Login", "เข้าสู่ระบบ")
	acc_title = pl.get_node("Row/Box/En")
	acc_title_th = pl.get_node("Row/Box/Th")
	v.add_child(pl)
	var g := GridContainer.new()
	g.columns = 2
	g.add_theme_constant_override("h_separation", 12)
	g.add_theme_constant_override("v_separation", 8)
	v.add_child(g)
	g.add_child(_field_label("Account", "บัญชี"))
	name_edit = _line_edit("account name (3-16 letters)", 16)
	g.add_child(name_edit)
	g.add_child(_field_label("Password", "รหัสผ่าน"))
	var pr := HBoxContainer.new()
	pr.add_theme_constant_override("separation", 6)
	pass_edit = _line_edit("at least 4 characters", 64)
	pass_edit.secret = true
	pass_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	pr.add_child(pass_edit)
	show_pass = _button("Show", func(): pass, BTN_GREY, Vector2(64, 34), 14)
	show_pass.toggle_mode = true
	show_pass.focus_mode = Control.FOCUS_CLICK
	show_pass.tooltip_text = "Show / hide the password  (แสดง / ซ่อนรหัสผ่าน)"
	show_pass.toggled.connect(_on_show_pass)
	pr.add_child(show_pass)
	g.add_child(pr)
	_pass2_label = _field_label("Confirm", "ยืนยันรหัส")
	g.add_child(_pass2_label)
	pass2_edit = _line_edit("type the password again", 64)
	pass2_edit.secret = true
	g.add_child(pass2_edit)
	g.add_child(_field_label("Server", "เซิร์ฟเวอร์"))
	var sr := HBoxContainer.new()
	sr.add_theme_constant_override("separation", 4)
	ip_edit = _line_edit("IP, e.g. 192.168.1.6", 64)
	ip_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	sr.add_child(ip_edit)
	sr.add_child(_label(":", 18, INK))
	port_edit = _line_edit(str(NetProto.DEFAULT_PORT), 5)
	port_edit.custom_minimum_size = Vector2(76, 36) * K
	port_edit.tooltip_text = "Port (default %d)" % NetProto.DEFAULT_PORT
	sr.add_child(port_edit)
	g.add_child(sr)
	remember = CheckBox.new()
	remember.text = "Remember account + server (never the password)"
	remember.add_theme_font_size_override("font_size", int(14 * K))
	remember.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
	v.add_child(remember)
	status = _wrap_label("", 14, INK_SOFT, 400)
	v.add_child(status)
	status_th = _wrap_label("", 13, INK_SOFT, 400)
	v.add_child(status_th)
	var r := HBoxContainer.new()
	r.alignment = BoxContainer.ALIGNMENT_CENTER
	r.add_theme_constant_override("separation", 12)
	v.add_child(r)
	back_btn = _button("Back", func(): _goto("title"), BTN_GREY, Vector2(120, 44), 17)
	r.add_child(back_btn)
	primary_btn = _button("Login", _on_primary, BTN_GREEN, Vector2(220, 44), 19)
	r.add_child(primary_btn)
	switch_btn = Button.new()
	switch_btn.flat = true
	switch_btn.add_theme_font_size_override("font_size", int(15 * K))
	for c in ["font_color", "font_focus_color"]:
		switch_btn.add_theme_color_override(c, Color("#2b5f95"))
	switch_btn.add_theme_color_override("font_hover_color", Color("#3f7fc0"))
	switch_btn.add_theme_constant_override("outline_size", 0)
	switch_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
	switch_btn.pressed.connect(func(): _goto("login" if screen == "register" else "register"))
	v.add_child(switch_btn)
	for le in [name_edit, pass_edit, pass2_edit, ip_edit, port_edit]:
		(le as LineEdit).text_submitted.connect(func(_t): _enter_from(le))


func _make_chars_panel(center: Control) -> void:
	chars_panel = _card(center)
	var v := _vbox(chars_panel, 10)
	var pl := _plaque("Choose your hero", "เลือกตัวละคร")
	chars_title = pl.get_node("Row/Box/En")
	var ph: HBoxContainer = pl.get_node("Row")
	chars_user = _label("", 14, Color("#f3d58a"))
	ph.add_child(chars_user)
	v.add_child(pl)
	chars_box = HBoxContainer.new()
	chars_box.alignment = BoxContainer.ALIGNMENT_CENTER
	chars_box.add_theme_constant_override("separation", 12)
	v.add_child(chars_box)
	chars_status = _wrap_label("", 14, INK_SOFT, 600)
	v.add_child(chars_status)
	chars_status_th = _wrap_label("", 13, INK_SOFT, 600)
	v.add_child(chars_status_th)
	var r := HBoxContainer.new()
	r.add_theme_constant_override("separation", 12)
	v.add_child(r)
	logout_btn = _button("Log out", _on_back, BTN_GREY, Vector2(130, 40), 16)
	r.add_child(logout_btn)
	var sp := Control.new()
	sp.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	r.add_child(sp)
	chars_slots = _label("", 13, INK_SOFT)
	chars_slots.size_flags_vertical = Control.SIZE_SHRINK_CENTER
	r.add_child(chars_slots)


func _make_create_panel(center: Control) -> void:
	create_panel = _card(center)
	var v := _vbox(create_panel, 10)
	v.add_child(_plaque("Create a new hero", "สร้างตัวละครใหม่"))
	var h := HBoxContainer.new()
	h.add_theme_constant_override("separation", 16)
	v.add_child(h)
	_create_stage = _stage(Vector2(190, 230))
	h.add_child(_create_stage)
	var right := VBoxContainer.new()
	right.add_theme_constant_override("separation", 6)
	right.custom_minimum_size = Vector2(270, 0)
	h.add_child(right)
	right.add_child(_field_label("Hero name", "ชื่อตัวละคร"))
	create_name = _line_edit("1-16 letters / digits (Thai ok)", 16)
	create_name.text_submitted.connect(func(_t): _on_create_confirm())
	create_name.select_all_on_focus = false
	right.add_child(create_name)
	right.add_child(_wrap_label("Every hero starts in Aldoria town and picks a class on the first login: Defend&Counter, Magic Control, Hunt, Musician, Shadow Assassin and more.", 13, INK_SOFT, 270, HORIZONTAL_ALIGNMENT_LEFT))
	right.add_child(_wrap_label("ทุกตัวเริ่มที่เมือง Aldoria แล้วเลือกคลาสตอนเข้าเกมครั้งแรก", 13, INK_SOFT, 270, HORIZONTAL_ALIGNMENT_LEFT))
	create_msg = _wrap_label("", 14, INK_SOFT, 270, HORIZONTAL_ALIGNMENT_LEFT)
	right.add_child(create_msg)
	var r := HBoxContainer.new()
	r.alignment = BoxContainer.ALIGNMENT_CENTER
	r.add_theme_constant_override("separation", 12)
	v.add_child(r)
	r.add_child(_button("Cancel", func(): _goto("charselect"), BTN_GREY, Vector2(120, 44), 17))
	create_btn = _button("Create hero", _on_create_confirm, BTN_GREEN, Vector2(220, 44), 19)
	r.add_child(create_btn)


func _make_footer(outer: VBoxContainer) -> void:
	var f := HBoxContainer.new()
	outer.add_child(f)
	f.add_child(_outlined("Tab = next field   ·   Enter = OK   ·   Esc = back", 13))
	var sp := Control.new()
	sp.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	f.add_child(sp)
	# the settings window (settings.gd; in game: the gear by the minimap, Esc or Alt+O)
	var gear := Button.new()
	gear.name = "SettingsBtn"
	gear.text = "⚙  Settings  ·  ตั้งค่า"
	gear.tooltip_text = "Graphics, lighting, sound, keys, language"
	gear.focus_mode = Control.FOCUS_NONE
	gear.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED
	gear.add_theme_font_size_override("font_size", int(round(16 * K)))
	_button_styles(gear, BTN_GOLD)
	gear.pressed.connect(func(): Settings.open_menu())
	f.add_child(gear)
	f.add_child(_outlined("Pandolia Online  ·  net v%d" % NetProto.PROTOCOL, 13))


func _make_dialog(root: Control) -> void:
	dialog = ColorRect.new()
	(dialog as ColorRect).color = Color(0.05, 0.03, 0.02, 0.55)
	dialog.set_anchors_preset(Control.PRESET_FULL_RECT)
	dialog.visible = false
	root.add_child(dialog)
	var c := CenterContainer.new()
	c.set_anchors_preset(Control.PRESET_FULL_RECT)
	dialog.add_child(c)
	var p := _card(c)
	p.visible = true
	var v := _vbox(p, 10)
	v.add_child(_plaque("Delete hero?", "ลบตัวละคร?"))
	dialog_text = _wrap_label("", 16, INK, 360)
	v.add_child(dialog_text)
	v.add_child(_wrap_label("This cannot be undone.  ลบแล้วกู้คืนไม่ได้", 13, BAD, 360))
	var r := HBoxContainer.new()
	r.alignment = BoxContainer.ALIGNMENT_CENTER
	r.add_theme_constant_override("separation", 12)
	v.add_child(r)
	dialog_cancel = _button("Cancel", _cancel_delete, BTN_GREY, Vector2(130, 42), 17)
	r.add_child(dialog_cancel)
	dialog_ok = _button("Delete forever", _confirm_delete, BTN_RED, Vector2(180, 42), 17)
	r.add_child(dialog_ok)


# ---------------------------------------------------------------- screens

## Shows one screen and puts the keyboard focus on its first useful control.
func _goto(s: String) -> void:
	screen = s
	title_panel.visible = s == "title"
	login_panel.visible = s in ["login", "register"]
	chars_panel.visible = s == "charselect"
	create_panel.visible = s == "create"
	_logo_box.visible = not (_tiny and s in ["charselect", "create"])
	match s:
		"title":
			_focus(solo_btn)
		"login", "register":
			var reg := s == "register"
			acc_title.text = "Create an account" if reg else "Login"
			acc_title_th.text = "สมัครบัญชีใหม่บนเซิร์ฟเวอร์" if reg else "เข้าสู่ระบบ"
			primary_btn.text = "Create account" if reg else "Login"
			switch_btn.text = "Already have an account?  Login ›" if reg else "No account yet?  Register ›"
			_pass2_label.visible = reg
			pass2_edit.visible = reg
			if not reg:
				pass2_edit.text = ""
			_tab_order()
			_focus(_first_empty())
		"charselect":
			var first: Control = null
			for card in chars_box.get_children():
				var b = card.get_meta("main_button", null)
				if b and first == null:
					first = b
			if first:
				_focus(first)
		"create":
			create_name.text = ""
			create_msg.text = ""
			create_btn.disabled = false
			_fill_stage(_create_stage, "novice")
			_focus(create_name)


## Tab / Shift+Tab order of the account card (the confirm field only when registering).
## Keyboard focus after this frame (the control may be gone or hidden by then).
func _focus(c: Control) -> void:
	_do_focus.call_deferred(c)


func _do_focus(c) -> void:
	if is_instance_valid(c) and (c as Control).is_inside_tree() and (c as Control).is_visible_in_tree():
		(c as Control).grab_focus()


func _tab_order() -> void:
	var chain: Array = [name_edit, pass_edit]
	if screen == "register":
		chain.append(pass2_edit)
	chain += [ip_edit, port_edit, remember, primary_btn, switch_btn, back_btn]
	for i in chain.size():
		var a: Control = chain[i]
		var b: Control = chain[(i + 1) % chain.size()]
		a.focus_next = a.get_path_to(b)
		b.focus_previous = b.get_path_to(a)


## Required fields of the account card in order (account, password, confirm, server IP).
func _required() -> Array:
	var r: Array = [name_edit, pass_edit]
	if screen == "register":
		r.append(pass2_edit)
	r.append(ip_edit)
	return r


func _first_empty() -> Control:
	for le in _required():
		if (le as LineEdit).text.strip_edges() == "":
			return le
	return primary_btn


## Enter in a field: jump to the next empty required field, else submit.
func _enter_from(_le: LineEdit) -> void:
	var nxt := _first_empty()
	if nxt != primary_btn:
		nxt.grab_focus()
		return
	if not primary_btn.disabled:
		_on_primary()


func _on_show_pass(on: bool) -> void:
	pass_edit.secret = not on
	pass2_edit.secret = not on
	show_pass.text = "Hide" if on else "Show"


func _on_primary() -> void:
	if screen == "register":
		if pass2_edit.text != pass_edit.text:
			show_error("The two passwords do not match.")
			pass2_edit.grab_focus()
			return
		_connect("register", "")
	else:
		_connect("login", "")


func _on_back() -> void:
	chosen.emit("back", {})


# ---------------------------------------------------------------- character select

## Shows the account's heroes: one card per hero (Play / Delete), empty slots get a Create card.
func show_chars(info: Dictionary) -> void:
	account_info = info
	_del_armed = ""
	dialog.visible = false
	set_busy(false)
	_lock_until = 0
	chars_title.text = "Choose your hero"
	chars_user.text = "%s%s" % [str(info.get("user", "?")), "  ·  admin" if bool(info.get("admin", false)) else ""]
	_spin = _spin.filter(func(p): return is_instance_valid(p) and not chars_box.is_ancestor_of(p))
	for c in chars_box.get_children():
		chars_box.remove_child(c)
		c.queue_free()
	var list: Array = info.get("chars", [])
	var max_chars := int(info.get("max_chars", 3))
	for c in list:
		chars_box.add_child(_hero_card(c))
	for i in range(list.size(), max_chars):
		chars_box.add_child(_empty_card())
	chars_slots.text = load("res://scripts/ui/msg_th.gd").tr_msg("Heroes %d / %d" % [list.size(), max_chars])
	_goto("charselect")
	var note := str(info.get("note", ""))
	if note != "":
		show_notice(note, true)
	elif list.is_empty():
		show_notice("Create your first hero (a Novice).", true)
	elif list.size() >= max_chars:
		show_notice("Pick a hero. All %d slots are used (delete one to make a new hero)." % max_chars, true)
	else:
		show_notice("Pick a hero to play, or create a new one.", true)


func _hero_card(c: Dictionary) -> PanelContainer:
	var cn := str(c.get("name", "?"))
	var job := str(c.get("job", "novice"))
	var card := PanelContainer.new()
	var sb := _flat(PARCH_LIGHT, FRAME, 2, 10)
	sb.set_content_margin_all(10)
	card.add_theme_stylebox_override("panel", sb)
	card.custom_minimum_size = Vector2(_card_w(), 0)
	var v := VBoxContainer.new()
	v.add_theme_constant_override("separation", 3)
	card.add_child(v)
	var stage := _stage(Vector2(_card_w() / K - 20, _stage_h()))
	_fill_stage(stage, job)
	v.add_child(stage)
	var nl := _label(cn, 20, INK)
	nl.add_theme_font_override("font", _bold)
	nl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	nl.clip_text = true
	nl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
	v.add_child(nl)
	var jl := _label(load("res://scripts/ui/msg_th.gd").tr_msg("Lv %d  %s   ·   Job Lv %d" % [int(c.get("lv", 1)), job_label(job), int(c.get("job_lv", 1))]), 14, INK)
	jl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	v.add_child(jl)
	var mid := str(c.get("map", ""))
	var ml := _label(GameData.map_name(mid) if mid != "" else "Aldoria", 13, INK_SOFT)
	ml.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	ml.clip_text = true
	ml.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
	v.add_child(ml)
	var tl := _label(load("res://scripts/ui/msg_th.gd").tr_msg(last_played_text(int(c.get("played", 0)))), 13, INK_SOFT)
	tl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	v.add_child(tl)
	var pb := _button("Play", play.bind(cn), BTN_GREEN, Vector2(0, 42), 19)
	pb.tooltip_text = "Enter the world as %s" % cn
	v.add_child(pb)
	var db := Button.new()
	db.text = "Delete"
	db.flat = true
	db.add_theme_font_size_override("font_size", int(14 * K))
	for k in ["font_color", "font_focus_color"]:
		db.add_theme_color_override(k, Color("#a8402f"))
	db.add_theme_color_override("font_hover_color", BAD)
	db.add_theme_constant_override("outline_size", 0)
	db.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
	db.pressed.connect(delete_char.bind(cn))
	buttons.append(db)
	v.add_child(db)
	card.set_meta("main_button", pb)
	card.set_meta("hero", cn)
	return card


func _empty_card() -> PanelContainer:
	var card := PanelContainer.new()
	var sb := _flat(Color(PARCH_LIGHT, 0.55), Color(FRAME, 0.55), 2, 10)
	sb.set_content_margin_all(10)
	card.add_theme_stylebox_override("panel", sb)
	card.custom_minimum_size = Vector2(_card_w(), 0)
	var v := VBoxContainer.new()
	v.alignment = BoxContainer.ALIGNMENT_CENTER
	v.add_theme_constant_override("separation", 4)
	card.add_child(v)
	var sp := Control.new()
	sp.custom_minimum_size = Vector2(0, maxf(10.0, _stage_h() * 0.35))
	v.add_child(sp)
	var plus := _label("+", 56, Color(FRAME, 0.8))
	plus.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	v.add_child(plus)
	var el := _label("Empty slot", 16, INK_SOFT)
	el.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	v.add_child(el)
	var th := _label("ช่องว่าง", 13, INK_SOFT)
	th.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	v.add_child(th)
	var sp2 := Control.new()
	sp2.size_flags_vertical = Control.SIZE_EXPAND_FILL
	v.add_child(sp2)
	var cb := _button("Create hero", func(): _goto("create"), BTN_GOLD, Vector2(0, 42), 17)
	v.add_child(cb)
	card.set_meta("main_button", cb)
	return card


func _card_w() -> float:
	return (196.0 if _compact else 220.0) * K


func _stage_h() -> float:
	return 132.0 if _compact else 200.0


## "Last played 2 h ago" from a unix time (0 = unknown).
static func last_played_text(t: int) -> String:
	if t <= 0:
		return "Not played yet"
	var d := int(Time.get_unix_time_from_system()) - t
	if d < 120:
		return "Last played just now"
	if d < 3600:
		return "Last played %d min ago" % (d / 60)
	if d < 86400:
		return "Last played %d h ago" % (d / 3600)
	if d < 86400 * 60:
		return "Last played %d days ago" % (d / 86400)
	return "Last played " + Time.get_date_string_from_unix_time(t)


## The single-player hero (Solo card): "Continue: Boss · Lv 51 Defend&Counter", read-only peek at the save.
static func solo_summary() -> String:
	var last := str(CharStore._read_json(CharStore.INDEX).get("last", ""))
	var d := CharStore._read_json(CharStore.file_for(last)) if last != "" else {}
	if d.is_empty():
		d = CharStore._read_json(CharStore.LEGACY)
	if d.is_empty():
		return "เล่นคนเดียว  ·  start a new adventure"
	return "เล่นคนเดียว  ·  %s  Lv %d %s" % [str(d.get("name", "Hero")), int(d.get("base_lv", 1)), job_label(str(d.get("job", "novice")))]


func _on_create_confirm() -> void:
	create_char(create_name.text)


func show_notice(text: String, ok: bool) -> void:
	set_busy(false)
	if chars_status:
		chars_status.text = text
		chars_status.add_theme_color_override("font_color", GOOD if ok else BAD)
		chars_status_th.text = thai_hint(text)
	if screen == "create":
		create_msg.text = text + ("\n" + thai_hint(text) if thai_hint(text) != "" else "")
		create_msg.add_theme_color_override("font_color", GOOD if ok else BAD)
		create_btn.disabled = false


func play(char_name: String) -> void:
	set_busy(true)
	chars_status.text = "Entering the world as %s ..." % char_name
	chars_status.add_theme_color_override("font_color", INK_SOFT)
	chars_status_th.text = "กำลังเข้าสู่โลก..."
	chosen.emit("play", {"name": char_name})


func create_char(char_name: String) -> void:
	var n: String = NetProto.clean_name(char_name)
	if n == "" or n != char_name.strip_edges():
		show_notice("Hero names are 1-16 letters / digits / spaces (Thai ok).", false)
		return
	if screen == "create":
		create_msg.text = "Creating %s ..." % n
		create_msg.add_theme_color_override("font_color", INK_SOFT)
		create_btn.disabled = true
	chosen.emit("create", {"name": n})


## Delete asks first (a dialog); a second call within 5 s (or the dialog's red button) deletes.
func delete_char(char_name: String) -> void:
	var now := Time.get_ticks_msec() / 1000.0
	if _del_armed != char_name or now - _del_t > 5.0:
		_del_armed = char_name
		_del_t = now
		var info := ""
		for c in account_info.get("chars", []):
			if str(c.get("name", "")) == char_name:
				info = " (Lv %d %s)" % [int(c.get("lv", 1)), job_label(str(c.get("job", "novice")))]
		dialog_text.text = load("res://scripts/ui/msg_th.gd").tr_msg("%s%s will be deleted forever." % [char_name, info])
		dialog.visible = true
		_focus(dialog_cancel)
		show_notice("Delete %s? Confirm in the dialog." % char_name, false)
		return
	_confirm_delete()


func _confirm_delete() -> void:
	var n := _del_armed
	dialog.visible = false
	_del_armed = ""
	if n == "":
		return
	chars_status.text = "Deleting %s ..." % n
	chosen.emit("delete", {"name": n, "confirm": n})


func _cancel_delete() -> void:
	dialog.visible = false
	_del_armed = ""
	show_notice("Nothing was deleted.", true)
	_goto("charselect")


# ---------------------------------------------------------------- 3D previews

## Rounded "stage" behind a model preview (a SubViewport with its own small 3D world).
func _stage(sz: Vector2) -> PanelContainer:
	var p := PanelContainer.new()
	var sb := _flat(PARCH_DARK, Color(FRAME, 0.5), 1, 8)
	sb.bg_color = Color("#e8d6ae")
	sb.set_content_margin_all(0)
	p.add_theme_stylebox_override("panel", sb)
	p.custom_minimum_size = sz * K
	p.mouse_filter = Control.MOUSE_FILTER_IGNORE
	return p


## Puts the job's hero model (idle, slowly turning) on a stage. Headless: nothing (no rendering).
func _fill_stage(stage: PanelContainer, job: String) -> void:
	for ch in stage.get_children():
		stage.remove_child(ch)
		ch.queue_free()
	stage.set_meta("job", job)
	if _headless:
		return
	var svc := SubViewportContainer.new()
	svc.stretch = true
	svc.mouse_filter = Control.MOUSE_FILTER_IGNORE
	stage.add_child(svc)
	var vp := SubViewport.new()
	vp.own_world_3d = true
	vp.transparent_bg = true
	vp.msaa_3d = Viewport.MSAA_4X
	svc.add_child(vp)
	var we := WorldEnvironment.new()
	var env := Environment.new()
	env.background_mode = Environment.BG_CLEAR_COLOR
	env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
	env.ambient_light_color = Color(0.78, 0.72, 0.92)
	env.ambient_light_energy = 0.75
	env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
	we.environment = env
	vp.add_child(we)
	var sun := DirectionalLight3D.new()
	sun.rotation_degrees = Vector3(-35, 30, 0)
	sun.light_energy = 1.15
	sun.light_color = Color(1.0, 0.96, 0.88)
	vp.add_child(sun)
	var cam := Camera3D.new()
	cam.fov = 30.0
	vp.add_child(cam)
	var cp := Vector3(0, 1.0, 3.95)
	cam.transform = Transform3D(Basis.looking_at(Vector3(0, 0.84, 0) - cp), cp)
	var pivot := Node3D.new()
	vp.add_child(pivot)
	pivot.rotation.y = 0.35
	# soft contact shadow
	var disc := MeshInstance3D.new()
	var cm := CylinderMesh.new()
	cm.top_radius = 0.5
	cm.bottom_radius = 0.5
	cm.height = 0.01
	disc.mesh = cm
	var dm := StandardMaterial3D.new()
	dm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	dm.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	dm.albedo_color = Color(0.3, 0.2, 0.1, 0.3)
	cm.material = dm
	vp.add_child(disc)
	var m := hero_preview(job)
	if m:
		pivot.add_child(m)
		_spin.append(pivot)


## A light hero model for previews: only the idle clip (the game's ModelLoader.build loads every
## animation), toon materials, 1.75 m tall, facing +Z. Null when no model exists.
static func hero_preview(job: String) -> Node3D:
	for id in ["hero_" + job, "hero_novice"]:
		if not ModelLoader.has_model(id):
			continue
		var base := ModelLoader._load_glb(ModelLoader.model_dir(id) + "anim_idle.glb")
		if base == null:
			continue
		var w := Node3D.new()
		w.add_child(base)
		var s := 1.75 / maxf(ModelLoader._height(base), 0.01)
		base.scale = Vector3.ONE * s
		base.rotation.y = ModelLoader.FACING_FIX
		ModelLoader._toonify(base, 0.02)
		var ap := ModelLoader._anim_player(base)
		if ap and not ap.get_animation_list().is_empty():
			var an: String = ap.get_animation_list()[0]
			ap.get_animation(an).loop_mode = Animation.LOOP_LINEAR
			ap.play(an)
		w.set_meta("model_id", id)
		return w
	return null


# ---------------------------------------------------------------- theme + widgets

func _make_theme() -> void:
	var f := SystemFont.new()
	f.font_names = PackedStringArray(["Leelawadee UI", "Segoe UI", "Tahoma", "Arial"])
	_theme.default_font = f
	_theme.default_font_size = int(16 * K)
	_bold = SystemFont.new()
	_bold.font_names = f.font_names
	_bold.font_weight = 700
	var sb := _flat(PARCH, FRAME, 3, 12)
	sb.shadow_color = Color(0.1, 0.05, 0.02, 0.45)
	sb.shadow_size = 12
	sb.shadow_offset = Vector2(0, 4)
	sb.set_content_margin_all(18)
	_theme.set_stylebox("panel", "PanelContainer", sb)
	_theme.set_color("font_color", "Label", INK)
	var le := _flat(Color(1, 0.98, 0.92), FRAME, 2, 6)
	le.set_content_margin_all(8)
	_theme.set_stylebox("normal", "LineEdit", le)
	var lf := _flat(Color(1, 1, 0.96), Color("#d99a2b"), 3, 6)
	lf.set_content_margin_all(7)
	_theme.set_stylebox("focus", "LineEdit", lf)
	_theme.set_color("font_color", "LineEdit", INK)
	_theme.set_color("caret_color", "LineEdit", INK)
	_theme.set_color("selection_color", "LineEdit", Color(0.95, 0.75, 0.3, 0.5))
	_theme.set_color("font_placeholder_color", "LineEdit", Color(INK_SOFT, 0.6))
	for c in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
		_theme.set_color(c, "Button", Color(1, 1, 1))
	_theme.set_color("font_disabled_color", "Button", Color(0.97, 0.94, 0.88))
	_theme.set_color("font_outline_color", "Button", Color(0.18, 0.12, 0.06))
	_theme.set_constant("outline_size", "Button", 4)
	# keyboard focus: a gold ring (never invisible, Tab has to show where it is)
	var fr := StyleBoxFlat.new()
	fr.draw_center = false
	fr.border_color = FOCUS
	fr.set_border_width_all(3)
	fr.set_corner_radius_all(9)
	fr.expand_margin_left = 3
	fr.expand_margin_right = 3
	fr.expand_margin_top = 3
	fr.expand_margin_bottom = 3
	_theme.set_stylebox("focus", "Button", fr)
	_theme.set_stylebox("focus", "CheckBox", fr)
	for c in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
		_theme.set_color(c, "CheckBox", INK)
	_theme.set_constant("outline_size", "CheckBox", 0)
	_theme.set_icon("checked", "CheckBox", _check_icon(true))
	_theme.set_icon("unchecked", "CheckBox", _check_icon(false))
	var tp := _flat(PARCH, FRAME, 2, 6)
	_theme.set_stylebox("panel", "TooltipPanel", tp)
	_theme.set_color("font_color", "TooltipLabel", INK)
	var UiSkin = load("res://scripts/ui/ui_skin.gd")
	if UiSkin.v3():
		# UI v3 (ui/ui_skin.gd): Kanit + the parchment page panel; the lobby's own buttons stay
		_theme.default_font = UiSkin.font("Regular")
		_theme.set_stylebox("panel", "PanelContainer", UiSkin.window_panel(sb))
		_theme.set_stylebox("panel", "TooltipPanel", UiSkin.tooltip_panel(tp))


## Parchment-style check box icon (the default one is pale on cream).
static func _check_icon(on: bool) -> ImageTexture:
	var n := 20
	var img := Image.create(n, n, false, Image.FORMAT_RGBA8)
	img.fill(Color(0, 0, 0, 0))
	for y in n:
		for x in n:
			var edge := x < 2 or y < 2 or x >= n - 2 or y >= n - 2
			if edge:
				img.set_pixel(x, y, FRAME.darkened(0.2))
			else:
				img.set_pixel(x, y, BTN_GREEN if on else Color(1, 0.98, 0.92))
	if on:
		# white tick
		var pts := [[4, 10], [8, 14], [15, 5]]
		for i in 2:
			var a: Array = pts[i]
			var b: Array = pts[i + 1]
			for t in 24:
				var k := t / 23.0
				var px := int(round(lerpf(a[0], b[0], k)))
				var py := int(round(lerpf(a[1], b[1], k)))
				for oy in [-1, 0, 1]:
					for ox in [0, 1]:
						if px + ox > 1 and px + ox < n - 2 and py + oy > 1 and py + oy < n - 2:
							img.set_pixel(px + ox, py + oy, Color(1, 1, 1))
	return ImageTexture.create_from_image(img)


func _shader_mat(code: String) -> ShaderMaterial:
	var m := ShaderMaterial.new()
	var sh := Shader.new()
	sh.code = code
	m.shader = sh
	return m


func _flat(bg: Color, border: Color, bw: int, radius: int) -> StyleBoxFlat:
	var sb := StyleBoxFlat.new()
	sb.bg_color = bg
	sb.border_color = border
	sb.set_border_width_all(bw)
	sb.set_corner_radius_all(radius)
	sb.set_content_margin_all(7)
	return sb


func _card(parent: Control) -> PanelContainer:
	var p := PanelContainer.new()
	p.visible = false
	parent.add_child(p)
	return p


func _vbox(parent: Control, sep: int) -> VBoxContainer:
	var v := VBoxContainer.new()
	v.add_theme_constant_override("separation", sep)
	parent.add_child(v)
	return v


## Dark header plaque: English title (cream, bold) + Thai subtitle; node "Row" takes extra widgets.
func _plaque(en: String, th: String) -> PanelContainer:
	var p := PanelContainer.new()
	var sb := _flat(PLAQUE, Color("#2c1d12"), 2, 8)
	sb.content_margin_left = 14
	sb.content_margin_right = 14
	sb.content_margin_top = 6
	sb.content_margin_bottom = 6
	p.add_theme_stylebox_override("panel", sb)
	var row := HBoxContainer.new()
	row.name = "Row"
	row.add_theme_constant_override("separation", 10)
	p.add_child(row)
	var box := VBoxContainer.new()
	box.name = "Box"
	box.add_theme_constant_override("separation", -2)
	box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	row.add_child(box)
	var l := _label(en, 20, CREAM)
	l.name = "En"
	# the Thai line sits under it: the English title never auto-translates (th-left)
	l.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED
	l.add_theme_font_override("font", _bold)
	box.add_child(l)
	var t := _label(th, 15, Color("#e2cfa4"))
	t.name = "Th"
	box.add_child(t)
	var UiSkin = load("res://scripts/ui/ui_skin.gd")
	if UiSkin.v4():
		# UI v4 storybook: a glossy green header band with white bold text (like the v4 tabs / main buttons)
		var gb: StyleBox = UiSkin.tab_styles(true, 14.0, 6.0)[0]
		p.add_theme_stylebox_override("panel", gb)
		l.add_theme_color_override("font_color", Color("#ffffff"))
		l.add_theme_color_override("font_outline_color", Color(0.12, 0.34, 0.12, 0.85))
		l.add_theme_constant_override("outline_size", 5)
		t.add_theme_color_override("font_color", Color("#f2ffe6"))
		# labels added to the header row later (account name ...) stay readable on the green band
		row.child_entered_tree.connect(func(c: Node):
			if c is Label and c != box:
				(c as Label).add_theme_color_override("font_color", Color("#fffbe8"))
				(c as Label).add_theme_color_override("font_outline_color", Color(0.12, 0.34, 0.12, 0.85))
				(c as Label).add_theme_constant_override("outline_size", 4))
	return p


func _label(text: String, size: int, color: Color) -> Label:
	var l := Label.new()
	l.text = text
	l.add_theme_font_size_override("font_size", int(round(size * K)))
	l.add_theme_color_override("font_color", color)
	return l


## Display name of a job id (jobs.json "name" + "name_th"); an old first-tree job shows its Combat v2 class.
static func job_label(job: String) -> String:
	# an old save still names a first-tree job: it loads as its Combat v2 class (job_slots.gd migrate), show that
	var jd: Dictionary = GameData.jobs.get(job, {})
	if not bool(jd.get("v2", false)):
		var js: Dictionary = load("res://scripts/server/combat_v2.gd").data().get("job_slots", {})
		var to := str(js.get("migrate", {}).get(job, js.get("pick_default", "")))
		if GameData.jobs.has(to):
			jd = GameData.jobs[to]
	var en := str(jd.get("name", job.capitalize()))
	var th := str(jd.get("name_th", ""))
	return en + (" " + th if th != "" else "")


func _wrap_label(text: String, size: int, color: Color, width: float, align: int = HORIZONTAL_ALIGNMENT_CENTER) -> Label:
	var l := _label(text, size, color)
	l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	l.custom_minimum_size = Vector2(width * K, 0)
	l.horizontal_alignment = align
	return l


## White text with a dark outline, for text straight over the 3D backdrop.
func _outlined(text: String, size: int) -> Label:
	var l := _label(text, size, Color(1, 0.98, 0.92))
	l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	l.add_theme_color_override("font_outline_color", Color(0.12, 0.07, 0.04, 0.9))
	l.add_theme_constant_override("outline_size", 5)
	return l


## Field caption: English + small Thai under it.
func _field_label(en: String, th: String) -> VBoxContainer:
	var v := VBoxContainer.new()
	v.add_theme_constant_override("separation", -3)
	v.size_flags_vertical = Control.SIZE_SHRINK_CENTER
	var l := _label(en, 15, INK)
	l.add_theme_font_override("font", _bold)
	v.add_child(l)
	v.add_child(_label(th, 13, INK_SOFT))
	return v


func _line_edit(placeholder: String, max_len: int) -> LineEdit:
	var le := LineEdit.new()
	le.placeholder_text = placeholder
	le.max_length = max_len
	le.custom_minimum_size = Vector2(270, 36) * K
	le.select_all_on_focus = true
	return le


func _button_styles(b: Button, base: Color) -> void:
	var n := _flat(base, base.darkened(0.45), 2, 9)
	n.shadow_color = Color(0.1, 0.05, 0.02, 0.3)
	n.shadow_size = 2
	n.shadow_offset = Vector2(0, 2)
	b.add_theme_stylebox_override("normal", n)
	var h: StyleBoxFlat = n.duplicate()
	h.bg_color = base.lightened(0.15)
	b.add_theme_stylebox_override("hover", h)
	var p: StyleBoxFlat = n.duplicate()
	p.bg_color = base.darkened(0.2)
	p.shadow_size = 0
	b.add_theme_stylebox_override("pressed", p)
	b.add_theme_stylebox_override("hover_pressed", p)
	var d: StyleBoxFlat = n.duplicate()
	d.bg_color = Color(0.72, 0.66, 0.56)
	d.border_color = Color(0.55, 0.5, 0.42)
	b.add_theme_stylebox_override("disabled", d)
	var UiSkin = load("res://scripts/ui/ui_skin.gd")
	if UiSkin.v3():
		# UI v3: glossy textured button states in the same colour (ui/ui_skin.gd), same margins
		var st: Array = UiSkin.buttons(base, [n, h, p, d])
		b.add_theme_stylebox_override("normal", st[0])
		b.add_theme_stylebox_override("hover", st[1])
		b.add_theme_stylebox_override("pressed", st[2])
		b.add_theme_stylebox_override("hover_pressed", st[2])
		b.add_theme_stylebox_override("disabled", st[3])
		if UiSkin.v4() and UiSkin.Skin4.kind_of(base) == "cream":
			# v4 cream (secondary) buttons: dark brown text, no light outline
			UiSkin.Skin4.cream_text(b)
			b.add_theme_color_override("font_hover_pressed_color", UiSkin.Skin4.TXT)


func _button(text: String, cb: Callable, base: Color, min_size: Vector2 = Vector2(150, 40), font_size: int = 17) -> Button:
	var b := Button.new()
	b.text = text
	b.custom_minimum_size = min_size * K
	b.add_theme_font_size_override("font_size", int(round(font_size * K)))
	b.add_theme_font_override("font", _bold)
	_button_styles(b, base)
	b.pressed.connect(cb)
	buttons.append(b)
	return b


## Button with a big title and a small line under it (node "Box/Extra").
func _big_button(title: String, sub: String, cb: Callable, base: Color, min_size: Vector2, font_size: int) -> Button:
	var b := _button("", cb, base, min_size, font_size)
	b.tooltip_text = title
	var box := VBoxContainer.new()
	box.name = "Box"
	box.set_anchors_preset(Control.PRESET_FULL_RECT)
	box.alignment = BoxContainer.ALIGNMENT_CENTER
	box.add_theme_constant_override("separation", -2)
	box.mouse_filter = Control.MOUSE_FILTER_IGNORE
	b.add_child(box)
	var t := _label(title, font_size, Color(1, 1, 1))
	t.name = "Title"
	t.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED
	t.add_theme_font_override("font", _bold)
	t.add_theme_color_override("font_outline_color", Color(0.15, 0.1, 0.05, 0.9))
	t.add_theme_constant_override("outline_size", 5)
	t.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	box.add_child(t)
	var s := _label(sub, 15, Color(1, 0.98, 0.9))
	s.name = "Extra"
	s.add_theme_color_override("font_outline_color", Color(0.15, 0.1, 0.05, 0.85))
	s.add_theme_constant_override("outline_size", 4)
	s.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	box.add_child(s)
	return b


## Smaller logo / preview stages on short windows (e.g. 1028 x 749).
func _on_resize() -> void:
	var h := get_viewport().get_visible_rect().size.y
	_compact = h < 1000.0
	_tiny = h < 820.0
	_logo.add_theme_font_size_override("font_size", 84 if _compact else 124)
	_tagline.add_theme_font_size_override("font_size", 20 if _compact else 24)
	if _logo.material:
		(_logo.material as ShaderMaterial).set_shader_parameter("y0", 18.0 if _compact else 26.0)
		(_logo.material as ShaderMaterial).set_shader_parameter("y1", 94.0 if _compact else 140.0)
	if screen in SCREENS:
		_logo_box.visible = not (_tiny and screen in ["charselect", "create"])


## Turns every preview model toward the camera (screenshots).
func face_front() -> void:
	for p in _spin:
		if is_instance_valid(p):
			(p as Node3D).rotation.y = 0.3


## main.gd built the town backdrop: fade the sky gradient out.
func backdrop_ready() -> void:
	var tw := create_tween()
	tw.tween_property(_fallback_bg, "modulate:a", 0.0, 1.2)


# ---------------------------------------------------------------- settings

func _load() -> Dictionary:
	if not FileAccess.file_exists(save_path):
		return {}
	var d = JSON.parse_string(FileAccess.get_file_as_string(save_path))
	return d if typeof(d) == TYPE_DICTIONARY else {}


## Remembers account + server when ticked (the password is never written).
func _save() -> void:
	var a := " " + " ".join(OS.get_cmdline_user_args())
	if " --nettest=" in a or " --n3test=" in a or " --shotlobby=" in a:
		return
	var d := {"port": _port(), "remember": remember.button_pressed}
	if remember.button_pressed:
		d["name"] = name_edit.text.strip_edges()
		d["ip"] = ip_edit.text.strip_edges()
	var f := FileAccess.open(save_path, FileAccess.WRITE)
	if f:
		f.store_string(JSON.stringify(d))
		f.close()


func _port() -> int:
	var p := port_edit.text.strip_edges()
	return int(p) if p.is_valid_int() and int(p) > 0 and int(p) < 65536 else NetProto.DEFAULT_PORT


# ---------------------------------------------------------------- status / errors

func _is_busy() -> bool:
	return primary_btn.disabled and _lock_until == 0 and status.text.begins_with("Connecting")


func set_busy(on: bool) -> void:
	# character cards are rebuilt: forget their freed buttons
	buttons = buttons.filter(func(b): return is_instance_valid(b) and not b.is_queued_for_deletion())
	for b in buttons:
		b.disabled = on
	for le in [name_edit, pass_edit, pass2_edit, ip_edit, port_edit]:
		(le as LineEdit).editable = not on


func set_status(text: String) -> void:
	status.text = text
	status.add_theme_color_override("font_color", INK_SOFT)
	status_th.text = thai_hint(text)
	if screen == "title":
		title_msg.text = text
		title_msg.add_theme_color_override("font_color", INK_SOFT)
		title_msg_th.text = thai_hint(text)


## A failure (login refused, server unreachable, connection lost, host could not start): back to the
## login card (or stays on the title), English message + Thai hint, lockouts count down.
func show_error(text: String) -> void:
	set_busy(false)
	dialog.visible = false
	if screen in ["charselect", "create"]:
		_goto("login")
	status.text = text
	status.add_theme_color_override("font_color", BAD)
	status_th.text = thai_hint(text)
	title_msg.text = text
	title_msg.add_theme_color_override("font_color", BAD)
	title_msg_th.text = status_th.text
	_lock_until = 0
	var re := RegEx.create_from_string("Wait (\\d+) s")
	var m := re.search(text)
	if m:
		_lock_until = Time.get_ticks_msec() + int(m.get_string(1)) * 1000
		_lock_fmt = text.substr(0, m.get_start(1)).replace("%", "%%") + "%d" + text.substr(m.get_end(1)).replace("%", "%%")
		primary_btn.disabled = true
	if screen in ["login", "register"]:
		var bad_field: Control = null
		if text.contains("password") and not text.contains("attempts"):
			bad_field = pass2_edit if text.contains("match") else pass_edit
		elif text.contains("account name") or text.begins_with("No account"):
			bad_field = name_edit
		elif text.contains("IP"):
			bad_field = ip_edit
		if bad_field:
			_focus(bad_field)


## Thai line for the server / client messages players meet most ("" when there is none).
static func thai_hint(text: String) -> String:
	var t := text.to_lower()
	var map := [
		["wrong password", "รหัสผ่านไม่ถูกต้อง"],
		["too many login attempts", "ใส่รหัสผิดหลายครั้ง ระบบล็อกชั่วคราว รอนับถอยหลังแล้วลองใหม่"],
		["too many new accounts", "สมัครบัญชีจากเครื่องนี้มากเกินไป ลองใหม่ภายหลัง"],
		["cannot find the server", "หาเซิร์ฟเวอร์ไม่เจอ — ตรวจที่อยู่เซิร์ฟเวอร์ (เช่น xxx.playit.gg) แล้วลองใหม่"],
		["secure connection", "เชื่อมต่อแบบเข้ารหัสไม่สำเร็จ — อัปเดตเกมแล้วลองใหม่"],
		["could not reach", "ติดต่อเซิร์ฟเวอร์ไม่ได้ — เช็ค IP / พอร์ต ว่าเซิร์ฟเวอร์เปิดอยู่ และไฟร์วอลล์อนุญาต"],
		["no answer from the server", "เซิร์ฟเวอร์ไม่ตอบ (หมดเวลา) — เช็คเน็ต / IP แล้วลองใหม่"],
		["different game version", "มีแพตช์ใหม่ ปิดเกมแล้วเปิดใหม่ผ่าน Play Aldoria เพื่ออัปเดต"],
		["update your game", "มีแพตช์ใหม่ ปิดเกมแล้วเปิดใหม่ผ่าน Play Aldoria เพื่ออัปเดต"],
		["banned", "บัญชีนี้ถูกแบน ติดต่อเจ้าของเซิร์ฟเวอร์"],
		["no account named", "ไม่มีบัญชีนี้ — กด Register เพื่อสมัครก่อน"],
		["already taken", "ชื่อนี้มีคนใช้แล้ว ลองชื่ออื่น"],
		["already exists", "ชื่อนี้มีคนใช้แล้ว ลองชื่ออื่น"],
		["do not match", "รหัสผ่านสองช่องไม่ตรงกัน"],
		["enter your account", "ใส่ชื่อบัญชีก่อน"],
		["enter your password", "ใส่รหัสผ่าน (อย่างน้อย 4 ตัว)"],
		["server's ip", "ใส่ IP ของเซิร์ฟเวอร์ (ถามเจ้าของเครื่อง)"],
		["logged in from another place", "บัญชีนี้ถูกล็อกอินจากที่อื่น"],
		["kicked", "ถูกเตะออกจากเซิร์ฟเวอร์"],
		["lost", "การเชื่อมต่อหลุด"],
		["closed the connection", "เซิร์ฟเวอร์ปิดการเชื่อมต่อ"],
		["host left", "เจ้าของห้องออกจากเกมแล้ว"],
		["connecting", "กำลังเชื่อมต่อ..."],
		["starting the host", "กำลังเปิดห้อง..."],
		["hero names are", "ชื่อ 1-16 ตัว (ตัวอักษร / ตัวเลข / เว้นวรรค ภาษาไทยได้)"],
		["character names are", "ชื่อ 1-16 ตัว (ตัวอักษร / ตัวเลข / เว้นวรรค ภาษาไทยได้)"],
		["was created", "สร้างตัวละครแล้ว"],
		["was deleted", "ลบตัวละครแล้ว"],
		["create your first hero", "สร้างตัวละครตัวแรก (เริ่มเป็น Novice)"],
		["all 3 slots", "ช่องเต็มแล้ว ลบตัวเก่าก่อนถึงจะสร้างใหม่ได้"],
		["slots are used", "ช่องเต็มแล้ว ลบตัวเก่าก่อนถึงจะสร้างใหม่ได้"],
		["pick a hero", "เลือกตัวละครเพื่อเข้าเล่น หรือสร้างตัวใหม่"],
		["confirm in the dialog", "ยืนยันการลบในหน้าต่างที่เด้งขึ้น"],
		["nothing was deleted", "ยกเลิกแล้ว ไม่ได้ลบอะไร"],
		["port", "เปิดห้องไม่ได้ — พอร์ตอาจถูกใช้อยู่"],
	]
	for pair in map:
		if t.contains(str(pair[0])):
			return str(pair[1])
	return ""


func _on_solo() -> void:
	chosen.emit("solo", {})


func _on_host() -> void:
	_save()
	set_busy(true)
	set_status("Starting the host on port %d..." % _port())
	chosen.emit("host", {"port": _port()})


## Fills the fields and joins straight into the hero named like the account (the N2 network test:
## account = hero name, both created when missing).
func join_with(ip: String, port: int, char_name: String, pw: String) -> void:
	login_with(ip, port, char_name, pw, "auto", char_name)


## Fills the fields and logs in / registers (tests). play_char: "" = show the character select.
func login_with(ip: String, port: int, user: String, pw: String, mode: String = "login", play_char: String = "") -> void:
	ip_edit.text = ip
	port_edit.text = str(port)
	name_edit.text = user
	pass_edit.text = pw
	_goto("register" if mode == "register" else "login")
	pass2_edit.text = pw
	_connect(mode, play_char)


func _connect(mode: String, play_char: String) -> void:
	if screen not in ["login", "register"]:
		_goto("register" if mode == "register" else "login")
	var u := name_edit.text.strip_edges()
	if u == "":
		show_error("Enter your account name first (Register makes a new one).")
		return
	if pass_edit.text.length() < 4:
		show_error("Enter your password (at least 4 characters).")
		return
	var ip := ip_edit.text.strip_edges()
	if ip == "":
		show_error("Enter the server's IP address (ask the owner - Tailscale: 100.x.x.x).")
		return
	var addr: Array = NetProto.parse_address(ip, _port())
	_save()
	set_busy(true)
	_lock_until = 0
	set_status("Connecting to %s:%d ..." % [addr[0], int(addr[1])])
	chosen.emit("join", {"ip": str(addr[0]), "port": int(addr[1]), "user": u, "pw": pass_edit.text, "mode": mode, "char": play_char})


# ---------------------------------------------------------------- screenshots (fake data, no network)

## Fake account used by the screenshot mode and the self-test.
static func fake_account() -> Dictionary:
	var now := int(Time.get_unix_time_from_system())
	return {"user": "boss", "admin": true, "max_chars": 3, "note": "", "chars": [
		{"name": "Boss", "job": "defend_counter", "lv": 51, "job_lv": 38, "map": "aldoria_meadows", "played": now - 2 * 3600},
		{"name": "มะลิ", "job": "mage", "lv": 23, "job_lv": 14, "map": "", "played": now - 3 * 86400}]}


## Puts the title into one screen / state for a screenshot (SHOT_SCREENS). Nothing is sent.
func shot_setup(what: String) -> void:
	solo_line.text = "เล่นคนเดียว  ·  Boss  Lv 51 %s" % job_label("defend_counter")
	var fill := func():
		name_edit.text = "boss"
		pass_edit.text = "hunter22"
		ip_edit.text = "192.168.1.6"
		port_edit.text = str(NetProto.DEFAULT_PORT)
	match what:
		"title":
			_goto("title")
		"login":
			fill.call()
			pass_edit.text = ""
			_goto("login")
		"register":
			fill.call()
			name_edit.text = "mali"
			pass2_edit.text = ""
			_goto("register")
			_focus(pass2_edit)
		"connecting":
			fill.call()
			_goto("login")
			set_busy(true)
			set_status("Connecting to 192.168.1.6:%d ..." % NetProto.DEFAULT_PORT)
		"wrongpass", "lockout", "unreachable", "version":
			fill.call()
			_goto("login")
			show_error({"wrongpass": "Wrong password.", "lockout": "Too many login attempts. Wait 42 s and try again.",
				"unreachable": "Could not reach the server. Check the IP / port, that the server is running, and its firewall.",
				"version": "Different game version (server 3, you %d). Update your game." % NetProto.PROTOCOL}[what])
		"charselect", "create", "delete":
			show_chars(fake_account())
			if what == "create":
				_goto("create")
				create_name.text = "Kaito"
			elif what == "delete":
				delete_char("มะลิ")
