extends RefCounted
## Combat v2 class roster + Job Slots (docs/BOSS_QUEUE.md, Boss 2026-09-29). Pure static rules on a
## CharacterState `c`; only active with config "combat_v2".
##  - Classes are bound to weapons (jobs.json "weapons"); 5 launch classes, 4 defined but locked.
##  - Up to 4 slots: slot 1 free, slot 2 Base Lv50 + quest jobslot_2 + 1,000,000 G, slot 3 Lv99 + jobslot_3
##    + 5,000,000 G, slot 4 Lv150 + jobslot_4 + 20,000,000 G (data/combat_v2.json "job_slots").
##  - Per slot (Boss 2026-10-02, per-class levels): class, its own Base Lv / Base EXP, Job Lv / Job EXP, skill
##    points + skills, stat allocation (stats + free points), hotbar. A new class starts at Base Lv 1; switching back
##    restores that class's own level. Shared: gold, bag, storage, gear ownership, quests, mounts, asura, homestead.
##    Taking up another class needs the MAIN class (slot 1) at Base Lv 50 (main_lv / change_block).
##  - The active slot lives in the usual CharacterState fields (job, job_lv, skills, stats, ...), so all
##    the old code keeps working; `c.job_slots[i]` holds the snapshots (the active one is refreshed on save / switch).
##  - Switch by menu or by equipping another slot's weapon: out of combat only, 10 s cooldown.
##  - Old saves: Classic job -> nearest class (combat_v2.json "migrate"), full free stat + skill refund.
##    Merchant / Blacksmith / Novice (no clear match): the player picks one launch class once (c.class_pick).

const CombatV2 = preload("res://scripts/server/combat_v2.gd")

const STATS := ["str", "agi", "vit", "int", "dex", "luk"]
const SLOT_KEYS := ["job", "job_lv", "job_exp", "skill_points", "sp_bonus", "skills", "stats", "stat_points", "hotbar", "wheel",
	"base_lv", "base_exp"]
## Base Lv the MAIN class (slot 1) needs before any other class can be taken up (data "class_change_lv").
const CHANGE_LV := 50


static func cfg() -> Dictionary:
	return CombatV2.data().get("job_slots", {})


static func is_v2_job(job: String) -> bool:
	return bool(GameData.jobs.get(job, {}).get("v2", false))


static func is_locked(job: String) -> bool:
	return bool(GameData.jobs.get(job, {}).get("locked", false))


static func launch_classes() -> Array:
	return cfg().get("launch", [])


static func slot_rule(i: int) -> Dictionary:
	var s: Array = cfg().get("slots", [])
	return s[i] if i >= 0 and i < s.size() else {}


static func max_slots() -> int:
	return (cfg().get("slots", []) as Array).size()


## Stat points a character of this Base Lv has in total (48 at Lv1, + Lv/5 + 3 per level up).
static func total_stat_points(base_lv: int) -> int:
	var t := 48
	for l in range(1, base_lv):
		t += l / 5 + 3
	return t


## A fresh slot of `job`: Base Lv `base_lv` (a new class starts at 1), Job Lv 1, all stats 1 with every point of
## that Base Lv to spend.
static func fresh_slot(job: String, base_lv: int = 1) -> Dictionary:
	var st := {}
	for s in ["str", "agi", "vit", "int", "dex", "luk"]:
		st[s] = 1
	var hb: Array = []
	hb.resize(9)
	hb.fill("")
	var b := bonus_sp(base_lv)
	return {"job": job, "job_lv": 1, "job_exp": 0, "skill_points": b, "sp_bonus": b, "skills": {}, "stats": st,
		"stat_points": total_stat_points(base_lv), "hotbar": hb, "wheel": {}, "base_lv": base_lv, "base_exp": 0}


static func snapshot(c) -> Dictionary:
	var d := {}
	for k in SLOT_KEYS:
		var v = c.get(k)
		d[k] = v.duplicate(true) if v is Dictionary or v is Array else v
	return d


static func restore(c, d: Dictionary) -> void:
	c.job = str(d.get("job", c.job))
	# per-class levels: the slot's own Base Lv / EXP (older snapshots without them keep the live values)
	if d.has("base_lv"):
		c.base_lv = maxi(1, int(d["base_lv"]))
		c.base_exp = maxi(0, int(d.get("base_exp", 0)))
	c.job_lv = int(d.get("job_lv", 1))
	c.job_exp = int(d.get("job_exp", 0))
	c.skill_points = int(d.get("skill_points", 0))
	c.sp_bonus = int(d.get("sp_bonus", 0))
	c.skills = {}
	var sk: Dictionary = d.get("skills", {})
	for k in sk:
		c.skills[str(k)] = int(sk[k])
	var st: Dictionary = d.get("stats", {})
	for s in STATS:
		c.stats[s] = int(st.get(s, 1))
	c.stat_points = int(d.get("stat_points", 0))
	var hb: Array = d.get("hotbar", [])
	for i in c.hotbar.size():
		c.hotbar[i] = str(hb[i]) if i < hb.size() else ""
	c.wheel = {}
	var wh: Dictionary = d.get("wheel", {})
	for k in wh:
		c.wheel[str(k)] = int(wh[k])
	c.v2_stacks = 0.0


## Bonus skill points a character of this Base Lv has per class (owner 2026-09-30: Job Lv alone lags the
## Base Lv skill gates below ~Lv50). config "skill_point_bonus" {"start", "per", "max"}: start + Base Lv / per,
## at most max. 0 without the key or with v2 off.
static func bonus_sp(base_lv: int) -> int:
	var b: Dictionary = GameData.config.get("skill_point_bonus", {})
	if b.is_empty() or not CombatV2.enabled():
		return 0
	return mini(int(b.get("max", 0)), int(b.get("start", 0)) + base_lv / maxi(1, int(b.get("per", 5))))


## Tops the bonus skill points of the active class and of every other slot up to the Base Lv (level up,
## loaded save, migration). "sp_bonus" remembers what was granted, so a reload never grants twice.
static func grant_bonus_sp(c) -> void:
	var want := bonus_sp(c.base_lv)
	if want > c.sp_bonus:
		c.skill_points += want - c.sp_bonus
		c.sp_bonus = want
	for i in c.job_slots.size():
		if i == c.active_slot:
			continue
		var s: Dictionary = c.job_slots[i]
		var had := int(s.get("sp_bonus", 0))
		# each class by its own Base Lv
		want = bonus_sp(int(s.get("base_lv", c.base_lv)))
		if want > had:
			s["skill_points"] = int(s.get("skill_points", 0)) + want - had
			s["sp_bonus"] = want


## Makes sure slot 0 exists and mirrors the live fields.
static func sync(c) -> void:
	if c.job_slots.is_empty():
		c.job_slots.append(snapshot(c))
		c.active_slot = 0
	else:
		c.active_slot = clampi(c.active_slot, 0, c.job_slots.size() - 1)
		c.job_slots[c.active_slot] = snapshot(c)


# ------------------------------------------------------------------ unlock

## "" when slot `i` (0-based) can be unlocked now as class `job`.
static func can_unlock(c, i: int, job: String) -> String:
	if not CombatV2.enabled():
		return "Job Slots are not available."
	if i != c.job_slots.size() or i >= max_slots():
		return "Unlock the Job Slots in order." if i < max_slots() else "No more Job Slots."
	var r := slot_rule(i)
	if not is_v2_job(job) or is_locked(job):
		return "That class is not available yet."
	for s in c.job_slots:
		if str(s.get("job", "")) == job:
			return "You already walk that path in another slot."
	# per-class levels: the slot requirements read the MAIN class (slot 1), which must be Lv 50 for any second class
	var why := change_block(c)
	if why != "":
		return why
	if main_lv(c) < int(r.get("lv", 1)):
		return "Requires Base Level %d." % int(r.get("lv", 1))
	var q := str(r.get("quest", ""))
	if q != "" and not c.quests.is_done(q):
		return "Finish the quest \"%s\" first." % str(GameData.quest(q).get("name", q))
	if c.zeny < int(r.get("gold", 0)):
		return "The guild fee is %s gold." % _gold(int(r.get("gold", 0)))
	return ""


static func unlock(c, i: int, job: String) -> String:
	var err := can_unlock(c, i, job)
	if err != "":
		return err
	c.zeny -= int(slot_rule(i).get("gold", 0))
	sync(c)
	c.job_slots.append(fresh_slot(job, 1))
	var st: Dictionary = GameData.jobs[job].get("starter", {})
	for k in st:
		c.add_item(k, int(st[k]))
	c.msg("Job Slot %d unlocked: %s!" % [i + 1, str(GameData.jobs[job].get("name", job))], Color(1, 0.85, 0.3))
	c.changed.emit()
	return ""


static func _gold(n: int) -> String:
	var s := str(n)
	var out := ""
	while s.length() > 3:
		out = "," + s.substr(s.length() - 3) + out
		s = s.substr(0, s.length() - 3)
	return s + out


# ------------------------------------------------------------------ switch

## "" when the character may switch to slot `i` now. `in_combat`: the entity fought in the last 5 s.
static func can_switch(c, i: int, in_combat: bool, now: float) -> String:
	if not CombatV2.enabled():
		return "Job Slots are not available."
	if i < 0 or i >= c.job_slots.size():
		return "That Job Slot is locked."
	if i == c.active_slot:
		return "Already active."
	if in_combat:
		return "You can't change class in combat."
	if now < c.slot_cd_until:
		return "Class change ready in %d s." % ceili(c.slot_cd_until - now)
	return ""


static func switch_to(c, i: int, in_combat: bool, now: float) -> String:
	var err := can_switch(c, i, in_combat, now)
	if err != "":
		return err
	sync(c)
	c.active_slot = i
	restore(c, c.job_slots[i])
	c.slot_cd_until = now + float(cfg().get("switch_cd", 10.0))
	# a slot that was never set up gets its class skills (AUTO / skill pad)
	ensure_skills(c)
	# the worn weapon must fit the new class: unequip it when it does not (it stays in the bag)
	if c.equipment.has("weapon") and not weapon_fits(str(c.job), c.weapon_type()):
		c.unequip("weapon")
	c.mounted = c.mounted and c.skill_lv("riding") > 0
	c.clamp_vitals()
	c.msg("Class: %s" % c.job_name(), Color(1, 0.85, 0.3))
	c.leveled.emit("job")
	c.changed.emit()
	return ""


static func weapon_fits(job: String, wtype: String) -> bool:
	var ws: Array = GameData.jobs.get(job, {}).get("weapons", [])
	return ws.is_empty() or wtype in ws


## Slot whose class uses this weapon type (-1 = none). The active slot wins a tie.
static func slot_for_weapon(c, wtype: String) -> int:
	if weapon_fits(str(c.job), wtype):
		return c.active_slot
	for i in c.job_slots.size():
		if i != c.active_slot and weapon_fits(str(c.job_slots[i].get("job", "")), wtype):
			return i
	return -1


## Testing aid (config "job_slot_free_unlock", Boss 2026-10-01): equipping a weapon that no Job Slot can use
## opens a slot for the class of that weapon for free (no level / quest / gold). When all slots are taken,
## the last inactive slot is given to the new class.
## The v2 class a free test slot would open for this weapon type ("" = off / none).
static func free_job_for_weapon(wtype: String, c = null) -> String:
	if not bool(GameData.config.get("job_slot_free_unlock", false)) or wtype == "" or not CombatV2.enabled():
		return ""
	# the test flag respects the main-class level too
	if c != null and change_block(c) != "":
		return ""
	for jid in GameData.jobs:
		var jd: Dictionary = GameData.jobs[jid]
		if bool(jd.get("v2", false)) and wtype in (jd.get("weapons", []) as Array):
			return str(jid)
	return ""


static func free_unlock_for_weapon(c, wtype: String) -> void:
	if not bool(GameData.config.get("job_slot_free_unlock", false)) or wtype == "" or not CombatV2.enabled():
		return
	if slot_for_weapon(c, wtype) >= 0:
		return
	if change_block(c) != "":
		return
	for jid in GameData.jobs:
		var jd: Dictionary = GameData.jobs[jid]
		if not bool(jd.get("v2", false)) or not (wtype in (jd.get("weapons", []) as Array)):
			continue
		sync(c)
		var i: int = c.job_slots.size()
		if i >= max_slots():
			i = max_slots() - 1 if c.active_slot != max_slots() - 1 else max_slots() - 2
			c.job_slots[i] = fresh_slot(str(jid), 1)
		else:
			c.job_slots.append(fresh_slot(str(jid), 1))
		var st: Dictionary = jd.get("starter", {})
		for k in st:
			c.add_item(k, int(st[k]))
		c.msg("Job Slot %d (test): %s" % [i + 1, str(jd.get("name", jid))], Color(1, 0.85, 0.3))
		c.changed.emit()
		return


## Base level up: per-class levels (Boss 2026-10-02) - only the active class levels; the inactive slots keep
## their own Base Lv and points (this used to hand them the stat points of the shared level).
static func on_base_level(_c, _gained: int) -> void:
	pass


## Base Lv of the MAIN class (slot 1).
static func main_lv(c) -> int:
	if c.job_slots.is_empty() or c.active_slot == 0:
		return int(c.base_lv)
	return int((c.job_slots[0] as Dictionary).get("base_lv", c.base_lv))


## "" when another class may be taken up; else why not (the main class needs Base Lv 50).
static func change_block(c) -> String:
	var need := int(cfg().get("class_change_lv", CHANGE_LV))
	if main_lv(c) < need:
		return "Requires Base Level %d on your main class (Job Slot 1) before taking up another class." % need
	return ""


## Save migration (per-class levels): slots saved before the redesign have no level of their own. Slot 1 (main)
## keeps the old shared Base Lv / EXP; every other slot starts at Base Lv 1 with all stat and skill points back to
## spend (its Job Lv stays). Returns true when anything changed.
static func ensure_levels(c) -> bool:
	if c.job_slots.is_empty():
		return false
	# slots saved before the redesign have no "base_lv" (checked before sync() writes the live one in)
	var old: Array = []
	for i in c.job_slots.size():
		if not (c.job_slots[i] as Dictionary).has("base_lv"):
			old.append(i)
	if old.is_empty():
		return false
	var lv0 := int(c.base_lv)
	var exp0 := int(c.base_exp)
	sync(c)
	for i in old:
		var s: Dictionary = c.job_slots[i]
		if i == 0:
			s["base_lv"] = lv0
			s["base_exp"] = exp0
		else:
			_reset_to_lv1(s)
	restore(c, c.job_slots[c.active_slot])
	return true


static func _reset_to_lv1(s: Dictionary) -> void:
	s["base_lv"] = 1
	s["base_exp"] = 0
	var st := {}
	for k in STATS:
		st[k] = 1
	s["stats"] = st
	s["stat_points"] = total_stat_points(1)
	var b := bonus_sp(1)
	s["skills"] = {}
	s["skill_points"] = maxi(0, int(s.get("job_lv", 1)) - 1) + b
	s["sp_bonus"] = b
	s["wheel"] = {}
	var hb: Array = s.get("hotbar", [])
	for i in hb.size():
		if str(hb[i]).begins_with("skill:"):
			hb[i] = ""


# ------------------------------------------------------------------ migration

## Old (Classic job tree) character -> new class, full free refund. Returns true when it changed anything.
static func migrate(c) -> bool:
	if is_v2_job(str(c.job)):
		return false
	var mp: Dictionary = cfg().get("migrate", {})
	var old := str(c.job)
	var to := str(mp.get(old, ""))
	c.class_pick = to == ""
	if to == "":
		to = str(cfg().get("pick_default", "defend_counter"))
	c.migrated_from = old
	c.job = to
	# Job Lv carries over, every point comes back to spend again. A second job (Knight, Wizard, ...) had
	# finished a first job before it: it comes in at the class's max Job Lv (playtest 2026-09-30 - keeping
	# only the second job's Job Lv dropped a Knight Job 30 from 78 spent points to 29)
	var mj := int(GameData.jobs[to].get("max_job", 50))
	c.job_lv = mj if str(GameData.jobs.get(old, {}).get("parent", "")) != "" else clampi(c.job_lv, 1, mj)
	c.job_exp = 0
	c.skills = {}
	c.skill_points = c.job_lv - 1
	c.sp_bonus = 0
	for s in STATS:
		c.stats[s] = 1
	c.stat_points = total_stat_points(c.base_lv)
	# hotkeys of retired classic skills move to their v2 replacement of the new class (combat_v2.json "v2_replaces")
	var rep: Dictionary = CombatV2.data().get("v2_replaces", {})
	var kit: Array = GameData.jobs[to].get("skills", [])
	for i in c.hotbar.size():
		var e := str(c.hotbar[i])
		if e.begins_with("skill:"):
			var nid := str(rep.get(e.substr(6), ""))
			c.hotbar[i] = ("skill:" + nid) if nid in kit and not c.hotbar.has("skill:" + nid) else ""
	c.wheel = {}
	c.mounted = false
	if c.equipment.has("weapon") and not weapon_fits(to, c.weapon_type()):
		c.unequip("weapon")
	var st: Dictionary = GameData.jobs[to].get("starter", {})
	for k in st:
		if c.count(k) <= 0:
			c.add_item(k, int(st[k]))
	c.job_slots = [snapshot(c)]
	c.active_slot = 0
	return true


## The one-time class choice after a migration without a clear match (Merchant / Blacksmith / Novice).
## AUTO / skill pad fix (Boss 2026-10-02): a class slot never stays skill-less. Every active class skill (not the
## ultimate) still at Lv 0 learns Lv 1 while free points last; every learned active skill (and the learned ultimate)
## gets a hotbar / skill-pad place. Returns how many skills it learned.
static func ensure_skills(c) -> int:
	if c == null or not is_v2_job(str(c.job)):
		return 0
	var n := 0
	for sid in GameData.jobs.get(str(c.job), {}).get("skills", []):
		var sk: Dictionary = GameData.skill(str(sid))
		if sk.is_empty() or bool(sk.get("passive", false)) or bool(sk.get("ult", false)):
			continue
		if int(c.skills.get(str(sid), 0)) <= 0 and c.can_learn(str(sid)) == "":
			c.skill_points -= 1
			c.skills[str(sid)] = 1
			n += 1
	for sid2 in GameData.jobs.get(str(c.job), {}).get("skills", []):
		var sk2: Dictionary = GameData.skill(str(sid2))
		if not sk2.is_empty() and not bool(sk2.get("passive", false)) and not bool(sk2.get("ult", false)) and int(c.skills.get(str(sid2), 0)) > 0:
			if c.hotbar.find("skill:" + str(sid2)) < 0 and c.hotbar.find("") >= 0:
				c.hotbar[c.hotbar.find("")] = "skill:" + str(sid2)
	if n > 0:
		c.msg("Learned your class skills (Lv 1) - they are on the skill pad.", Color(0.6, 0.9, 1))
		c.changed.emit()
	return n


static func pick_class(c, job: String) -> String:
	if not c.class_pick:
		return "No class choice pending."
	if not (job in launch_classes()):
		return "Pick one of the launch classes."
	var old_w: String = str(c.weapon_type()) if c.equipment.has("weapon") else ""
	c.job = job
	c.class_pick = false
	if old_w != "" and not weapon_fits(job, old_w):
		c.unequip("weapon")
	var st: Dictionary = GameData.jobs[job].get("starter", {})
	for k in st:
		if c.count(k) <= 0:
			c.add_item(k, int(st[k]))
	# sweep: wield the starter weapon when the hands are empty (new heroes fought bare-handed)
	if not c.equipment.has("weapon"):
		for k in st:
			if str(GameData.item(str(k)).get("slot", "")) == "weapon" and weapon_fits(job, str(GameData.item(str(k)).get("wtype", ""))):
				c.equip(str(k))
				break
	ensure_skills(c)
	sync(c)
	c.msg("You are now a %s." % c.job_name(), Color(1, 0.85, 0.3))
	c.leveled.emit("job")
	c.changed.emit()
	return ""
