extends RefCounted
## Asura Rider combat (Boss 2026-10-01, docs/research/DRACONIA_DRAGON_RIDE.md part C): while riding, the BEAST
## performs every attack. Data: data/asura_moves.json (per beast: basic 3-hit chain + gap / aoe / burst / control
## moves, ride_rules, class_roles overrides).
##  - role_for(): a class skill -> a beast move role (auto-classified from skills.json, then the free roles in the
##    order burst > aoe > control > gap so a class's skills use distinct moves; cached per ride);
##  - start_move(): the real displacement on the server (walkable cells only, stops short of the target, PvP
##    distance x pvp_disp_mult) and the "asura_move" event every client plays (beast motion + FX + MEGA tag);
##    returns the move's hit times, the class skill's own hits are spread over them (skills_v2.gd), so its damage
##    stays the class skill's x Mega.
## The rider never attacks (player.gd / skill_fx.gd skip the rider's swing and cast poses while riding).

const Asura = preload("res://scripts/server/asura.gd")
const PATH := "res://data/asura_moves.json"
const ROLES := ["burst", "aoe", "control", "gap"]
static var _data: Dictionary = {}


static func data() -> Dictionary:
	if _data.is_empty():
		var f := FileAccess.open(PATH, FileAccess.READ)
		if f:
			var d = JSON.parse_string(f.get_as_text())
			if d is Dictionary:
				_data = d
	return _data


static func rules() -> Dictionary:
	return data().get("ride_rules", {})


static func beast(id: String) -> Dictionary:
	return (data().get("beasts", {}) as Dictionary).get(id, {})


## Role a skill would like from its own data (first match wins).
static func classify(sid: String) -> String:
	var sk := GameData.skill(sid)
	if sk.has("dash") or bool(sk.get("leap", false)) or sk.has("backstep"):
		return "gap"
	var tgt := str(sk.get("target", "enemy"))
	if sk.has("freeze") or bool(sk.get("gather", false)) or bool(sk.get("pull", false)) or bool(sk.get("heal", false)) \
			or sk.has("knockback") or sk.has("armor_break") or tgt == "self" or tgt == "ally" or bool(sk.get("party", false)) \
			or (sk.has("buff") and float(sk.get("pct", 0)) <= 0.0):
		return "control"
	var shape := str(sk.get("shape", "single"))
	if tgt == "ground" or shape in ["circle", "cone", "line"] or sk.has("r") or sk.has("splash"):
		return "aoe"
	return "burst"


## The class's active skills (kit order) -> distinct roles; class_roles overrides first. Ults map to "aoe".
static func roles_of(c) -> Dictionary:
	var out := {}
	var used := {}
	var over: Dictionary = (data().get("class_roles", {}) as Dictionary).get(str(c.job), {})
	var kit: Array = []
	for sid in GameData.jobs.get(str(c.job), {}).get("skills", []):
		var sk := GameData.skill(str(sid))
		if bool(sk.get("passive", false)) or bool(sk.get("ult", false)) or sk.is_empty():
			continue
		# only combat skills get a beast move (Riding, Hiding... stay the class's own)
		if float(sk.get("pct", 0.0)) <= 0.0 and not bool(sk.get("heal", false)) and not sk.has("buff"):
			continue
		kit.append(str(sid))
	for sid in kit:
		if over.has(sid) and not used.has(str(over[sid])):
			out[sid] = str(over[sid])
			used[out[sid]] = true
	for sid in kit:
		if out.has(sid):
			continue
		var want := classify(sid)
		if used.has(want):
			want = ""
			for r in ROLES:
				if not used.has(r):
					want = r
					break
		if want == "":
			want = classify(sid)
		out[sid] = want
		used[want] = true
	return out


static func role_for(c, sid: String) -> String:
	if bool(GameData.skill(sid).get("ult", false)):
		return "aoe"
	var cache = c.get_meta("asura_roles") if c.has_meta("asura_roles") else null
	if not (cache is Dictionary) or str(cache.get("_job", "")) != str(c.job):
		cache = roles_of(c)
		cache["_job"] = str(c.job)
		c.set_meta("asura_roles", cache)
	return str(cache.get(sid, classify(sid)))


## Effective reach of a skill while riding: max(skill range, beast reach) x mega_range.
static func reach(c, base: float) -> float:
	var b := beast(Asura.active_id(c))
	return maxf(base, float(b.get("reach", 0.0))) * float(rules().get("mega_range", 1.2))


## Starts the beast's move for `role` ("basic" = the next of the 3-hit chain): displacement + the "asura_move" event.
## Returns the hit times (s from now).
static func start_move(pe, role: String, target, sid: String = "") -> Array:
	var c = pe.ch
	var id := Asura.active_id(c)
	var b := beast(id)
	if b.is_empty():
		return [0.0]
	var mv: Dictionary
	var mkey := role
	if role == "basic":
		var chain: Array = b.get("basic", [])
		if chain.is_empty():
			return [0.0]
		var i := int(c.get_meta("asura_basic_i", 0))
		c.set_meta("asura_basic_i", i + 1)
		mv = chain[i % chain.size()]
		mkey = "basic%d" % (i % chain.size())
	else:
		mv = (b.get("moves", {}) as Dictionary).get(role, {})
		if mv.is_empty():
			return [0.0]
	var from: Vector3 = pe.global_position
	var tp: Vector3 = from
	if typeof(target) == TYPE_OBJECT and is_instance_valid(target):
		tp = target.global_position
	elif target is Vector3:
		tp = target
	tp.y = 0.0
	var to := from
	var segs: Array = mv.get("disp", [])
	var pvp: bool = typeof(target) == TYPE_OBJECT and is_instance_valid(target) and target.has_method("take_hit")
	var side := 1.0 if int(c.get_meta("asura_side", 0)) % 2 == 0 else -1.0
	c.set_meta("asura_side", int(c.get_meta("asura_side", 0)) + 1)
	var out_segs: Array = []
	for sg in segs:
		var dest := _dest(pe, to, tp, sg, side, pvp)
		out_segs.append({"t": float(sg.get("t", 0.0)), "dur": float(sg.get("dur", 0.2)), "from": to, "to": dest})
		# the entity glides there in 5 steps (clients follow it; flyers' lift is view only)
		if pe.map:
			var t0 := float(sg.get("t", 0.0))
			var du := maxf(0.05, float(sg.get("dur", 0.2)))
			for k in range(1, 6):
				pe.map.delay(t0 + du * float(k) / 5.0, _place.bind(pe, to.lerp(dest, float(k) / 5.0)))
		to = dest
	var hits: Array = mv.get("hit", [0.2])
	var ev := {"t": "asura_move", "id": pe.id, "asura": id, "role": role, "move": mkey, "key": str(mv.get("key", "bite")),
		"name": str(mv.get("name", "")), "name_th": str(mv.get("name_th", "")), "skill": sid, "len": float(mv.get("len", 0.6)),
		"hit": hits, "shape": str(mv.get("shape", "cone")), "r": float(mv.get("r", 3.0)), "n": int(mv.get("n", 1)),
		"at": str(mv.get("at", "self")), "target_at": tp, "from": from, "to": to, "segs": out_segs,
		"cam": mv.get("cam", {}), "wide": bool(mv.get("wide", false)), "rain": bool(mv.get("rain", false)),
		"rings": bool(mv.get("rings", false)), "blink": bool(mv.get("blink", false)), "mega": sid != ""}
	pe.ev(ev)
	return hits


## Where a displacement segment ends: along its direction in 0.25 m steps while the ground is walkable.
static func _dest(pe, start: Vector3, tp: Vector3, sg: Dictionary, side: float, pvp: bool) -> Vector3:
	var m := float(sg.get("m", 0.0)) * (float(rules().get("pvp_disp_mult", 0.6)) if pvp else 1.0)
	var fwd := tp - start
	fwd.y = 0.0
	var dist := fwd.length()
	fwd = fwd.normalized() if dist > 0.05 else Vector3(sin(pe.rotation.y), 0, cos(pe.rotation.y))
	var dir := fwd
	var short := float(rules().get("stop_short", 1.2))
	match str(sg.get("dir", "fwd")):
		"back":
			dir = -fwd
		"side_alt":
			dir = fwd.cross(Vector3.UP).normalized() * side
		"to_target":
			m = minf(m, maxf(0.0, dist - short))
		"target_point":
			m = minf(m, dist)
		"behind_target":
			if dist <= m + 2.0:
				var spot := tp + fwd * short
				return spot if _ok(pe, spot) else _walk(pe, start, fwd, maxf(0.0, dist - short))
			m = minf(m, maxf(0.0, dist - short))
	return _walk(pe, start, dir, m)


static func _walk(pe, start: Vector3, dir: Vector3, m: float) -> Vector3:
	var p := start
	var step := 0.25
	var n := int(m / step)
	for i in n:
		var q := p + dir * step
		if not _ok(pe, q):
			break
		p = q
	return p


static func _ok(pe, p: Vector3) -> bool:
	return pe.map == null or pe.map.walkable(p)


static func _place(pe, p: Vector3) -> void:
	if not is_instance_valid(pe) or pe.dead or not Asura.riding(pe.ch):
		return
	pe.global_position = Vector3(p.x, 0.0, p.z)
	pe.position.y = 0.0


## The skill's hit i of `hits` lands at which of the move's hit times (spread evenly, keeps the hit count).
static func hit_time(times: Array, i: int, hits: int) -> float:
	if times.is_empty():
		return 0.0
	return float(times[mini(times.size() - 1, int(float(i) * float(times.size()) / float(maxi(1, hits))))])
