## BulletManager — 子弹管理器 Autoload（GDScript 接口层）
## 热路径由 C# BulletManagerCs 子节点驱动。
## 权威来源：architecture_design.md §4.2
##
## SoA 完整布局（BULLET_STRIDE=12）：
## [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ]
##   0  1   2   3      4       5          6              7           8          9          10           11
## 冷数据（pierce/bounce/homing/payload_id）→ _bullet_contexts: Dictionary
extends Node

const BULLET_STRIDE: int = 12   # 禁止裸整数 12（跨切片约束）
const MAX_BULLETS:   int = 2048 # 预分配 SoA 大小
# 归航重选失败后的退避时长（60fps 下把最坏情况摊薄约 12×）。
# 一值两用且刻意耦合：既是基础退避时长，也是抖动窗口宽度 —— 到期时刻均匀铺满
# 「恰好一个退避周期」正是消除集体重试尖峰所需的性质，拆成两个常量反而会掩盖这层关系。
const HOMING_RETRY_MS: int = 200

# SoA 热数组（由 BulletManagerCs 直接读写）
var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0

# 冷数据（非热路径）
var _bullet_contexts: Dictionary = {}

var _cs_node: Node = null

func _ready() -> void:
	_data.resize(MAX_BULLETS * BULLET_STRIDE)
	_data.fill(0.0)

func _physics_process(delta: float) -> void:
	if not _cs_node:
		_gd_integrate(delta)

# GDScript 回退路径（C# 未就绪时 / S0/S1 基线验证）
func _gd_integrate(delta: float) -> void:
	var has_cold: bool = not _bullet_contexts.is_empty()
	var i: int = 0
	while i < _active_count:
		var base: int = i * BULLET_STRIDE
		# homing 判别在调用方完成：绝大多数冷数据子弹（纯 pierce/bounce/状态/荷载）
		# 不归航，避免为它们付一次完整函数调用（实测 1500 弹省约 64%）
		if has_cold and _bullet_contexts.has(i) and _bullet_contexts[i].has("homing_strength"):
			_apply_homing(i, base, delta)          # 转向须在位置积分之前，子弹当帧即沿新方向前进
		_data[base + 0] += _data[base + 2] * delta  # x += vx * dt
		_data[base + 1] += _data[base + 3] * delta  # y += vy * dt
		_data[base + 2] += _data[base + 11] * delta # vx += accel * dt
		_data[base + 3] += _data[base + 11] * delta # vy += accel * dt
		_data[base + 4] -= delta                     # lifetime -= dt
		if _data[base + 4] <= 0.0:
			_swap_and_pop(i)
		elif _check_collision(i, base):
			# 命中处理已在 _check_collision 内完成，子弹被回收
			pass  # _active_count 已减少，i 不递增
		else:
			i += 1

# S1 碰撞检测：数据驱动，使用 SpatialGrid 查询
# 命中后扣血；pierce_remaining=0 时回收子弹；返回 true 表示子弹已被回收
func _check_collision(bullet_idx: int, base: int) -> bool:
	var bx:     float = _data[base + 0]
	var by:     float = _data[base + 1]
	var br:     float = _data[base + 5]  # bullet radius
	var b_dmg:  float = _data[base + 6]  # base_damage
	var b_mult: float = _data[base + 7]  # damage_mult
	var b_type: int   = int(_data[base + 8])
	var b_own:  int   = int(_data[base + 9])
	var query_r: float = br + 16.0  # 16px = 敌人碰撞体估算半径
	var hits: PackedInt32Array = SpatialGrid.query_circle(Vector2(bx, by), query_r)
	if hits.is_empty():
		return false
	for entity_id in hits:
		var ene_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id)
		if ene_pos == Vector2(-9999.0, -9999.0):
			continue
		var dx: float = bx - ene_pos.x
		var dy: float = by - ene_pos.y
		var dist_sq: float = dx * dx + dy * dy
		if dist_sq > query_r * query_r:
			continue
		if _bullet_contexts.has(bullet_idx):
			var vt = _bullet_contexts[bullet_idx].get("visited_targets", null)
			if vt != null and entity_id in vt:
				continue   # 弹跳已命中过此敌：跳过，避免密集群内重复命中
		# 命中：通过 DamageContextPool 发出伤害
		var ctx_id: int = DamageContextPool.acquire(b_dmg, b_mult, b_type, b_own, false, 0.0)
		EnemyManager.apply_damage_from_context(entity_id, ctx_id)
		DamageContextPool.release(ctx_id)
		var hit_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id)
		if hit_pos == Vector2(-9999.0, -9999.0):
			hit_pos = Vector2(bx, by)
		VFXManager.play("hit_spark", hit_pos)
		EventBus.emit(EventID.BULLET_HIT, {"bullet_id": bullet_idx, "target_id": entity_id, "hit_pos": hit_pos})
		# 无冷数据的普通子弹：无状态/荷载/穿透，直接回收
		# （避免 .get(bullet_idx, {}) 每次命中都分配一个空字典默认值）
		if not _bullet_contexts.has(bullet_idx):
			_swap_and_pop(bullet_idx)
			return true
		# S4: 命中施加状态 / 连击标记
		var cold: Dictionary = _bullet_contexts[bullet_idx]
		var status_id: int = int(cold.get("apply_status_id", -1))
		if status_id > 0:
			StatusManager.apply(entity_id, status_id, 1, -1.0, b_own)
		if cold.get("apply_combo_mark", false):
			StatusManager.apply(entity_id, StatusID.COMBO_MARK, 1, 2.0, b_own)
		# S3: 命中触发子荷载
		var payload_id: int = int(cold.get("on_hit_payload_id", -1))
		if payload_id >= 0:
			var parent_depth: int = int(cold.get("trigger_parent_depth", 0))
			SpellEvaluator.execute_sub(payload_id, hit_pos, b_own, parent_depth + 1)
		# bounce 处理（优先于 pierce）：命中后弹向最近未访问敌人，伤害递减
		var bounce: int = int(cold.get("bounce_remaining", 0))
		if bounce > 0:
			var visited: Array = cold.get("visited_targets", [])
			visited.append(entity_id)
			var next_id: int = _find_nearest_unvisited(Vector2(bx, by), float(cold.get("bounce_range", 250.0)), visited)
			if next_id >= 0:
				var np: Vector2 = EnemyManager.get_pos_by_id(next_id)
				var spd: float = Vector2(_data[base + 2], _data[base + 3]).length()
				var dir: Vector2 = (np - Vector2(bx, by)).normalized()
				_data[base + 2] = dir.x * spd
				_data[base + 3] = dir.y * spd
				_data[base + 7] *= float(cold.get("bounce_decay", 0.9))
				if cold.has("homing_strength"):
					cold["homing_target_id"] = next_id   # bounce 选目标，homing 接手追上去
					cold.erase("homing_retry_at")        # 新目标有效，撤销退避（与重选成功路径对称）
				cold["bounce_remaining"] = bounce - 1
				cold["visited_targets"]  = visited
				_bullet_contexts[bullet_idx] = cold
				return false
			_swap_and_pop(bullet_idx)
			return true
		# pierce 处理
		var pierce: int = int(cold.get("pierce_remaining", 0))
		if pierce > 0:
			cold["pierce_remaining"] = pierce - 1
			_bullet_contexts[bullet_idx] = cold
			return false  # 穿透：子弹继续飞行
		_swap_and_pop(bullet_idx)
		return true  # 子弹已回收
	return false

func _swap_and_pop(idx: int) -> void:
	var last: int = _active_count - 1
	if idx != last:
		var base_idx:  int = idx  * BULLET_STRIDE
		var base_last: int = last * BULLET_STRIDE
		for s in BULLET_STRIDE:
			_data[base_idx + s] = _data[base_last + s]
		if _bullet_contexts.has(last):
			_bullet_contexts[idx] = _bullet_contexts[last]
			_bullet_contexts.erase(last)
		elif _bullet_contexts.has(idx):
			_bullet_contexts.erase(idx)
	else:
		# idx == last：无搬移，但它的冷数据同样必须清掉，
		# 否则遗留在该 key 上被下一颗复用此槽位的子弹继承（erase 缺键为安全 no-op）
		_bullet_contexts.erase(idx)
	_active_count -= 1

# ── 对外接口 ─────────────────────────────────────────────────────
## 生成子弹，返回 bullet_id（比 -1 表示满容）
## 参数附带冷数据：cold_data = {"on_hit_payload_id": int, "pierce_remaining": int, ...}
func spawn_bullet(pos: Vector2, vel: Vector2, lifetime: float, radius: float,
				  base_damage: float, damage_mult: float, damage_type: int,
				  owner_id: int, source_tags: int = 0, acceleration: float = 0.0,
				  cold_data: Dictionary = {}) -> int:
	if _active_count >= MAX_BULLETS:
		return -1
	var bullet_id: int = _active_count
	var base: int = bullet_id * BULLET_STRIDE
	_data[base + 0]  = pos.x
	_data[base + 1]  = pos.y
	_data[base + 2]  = vel.x
	_data[base + 3]  = vel.y
	_data[base + 4]  = lifetime
	_data[base + 5]  = radius
	_data[base + 6]  = base_damage
	_data[base + 7]  = damage_mult
	_data[base + 8]  = float(damage_type)
	_data[base + 9]  = float(owner_id)
	_data[base + 10] = float(source_tags)
	_data[base + 11] = acceleration
	if not cold_data.is_empty():
		_bullet_contexts[bullet_id] = cold_data
	_active_count += 1
	return bullet_id

## 提前回收：lifetime 置 0；C# 下帧 SoA 清理
func despawn_bullet(bullet_id: int) -> void:
	if bullet_id < 0 or bullet_id >= _active_count:
		return
	_data[bullet_id * BULLET_STRIDE + 4] = 0.0  # slot +4: lifetime

func get_active_count() -> int:
	return _active_count

## 渲染：将活跃子弹的 SoA 位置批量写入 MultiMesh（§4.4，SoA 索引 ↔ instance 索引共享）
## visible_instance_count 控制渲染数量，避免每帧重分配 instance_count
func sync_multimesh(mm: MultiMesh) -> void:
	if mm == null:
		return
	var n: int = min(_active_count, mm.instance_count)
	mm.visible_instance_count = n
	for i in n:
		var base: int = i * BULLET_STRIDE
		var r: float = _data[base + 5]
		mm.set_instance_transform_2d(i, Transform2D(
			0.0, Vector2(r * 2.0, r * 2.0), 0.0,
			Vector2(_data[base + 0], _data[base + 1])))

## 弹跳/归航寻的：query_circle 半径内滤除已访问/哨兵，取最近 entity_id；无则 -1
func _find_nearest_unvisited(origin: Vector2, radius: float, visited: Array) -> int:
	var hits: PackedInt32Array = SpatialGrid.query_circle(origin, radius)
	var best_id: int = -1
	var best_sq: float = radius * radius
	for eid in hits:
		if eid in visited:
			continue
		var p: Vector2 = EnemyManager.get_pos_by_id(eid)
		if p == Vector2(-9999.0, -9999.0):
			continue
		var d: float = origin.distance_squared_to(p)
		if d <= best_sq:
			best_sq = d
			best_id = eid
	return best_id

## 归航转向：以 homing_strength(弧度/秒) 为上限把速度矢量转向锁定目标，速率不变
## 目标有效时零查询；目标失效（死亡）或首帧未锁定时经 _find_nearest_unvisited 重选。
## 重选成功即恢复零查询稳态；重选失败（场上无敌人 / 射程内无目标 / 射程内全在
## visited_targets 里）则子弹直行，并记 homing_retry_at 退避 HOMING_RETRY_MS 后再试 ——
## 否则该子弹余生每帧都会重跑 query_circle(r=400)（实测 ~10.5µs/帧/弹，1500 弹达 15.8ms）。
## 首次锁定不受退避影响（无该键时 get 返回 0 必小于 now），手感不回退。
func _apply_homing(bullet_idx: int, base: int, delta: float) -> void:
	var cold: Dictionary = _bullet_contexts[bullet_idx]
	var strength: float = float(cold.get("homing_strength", 0.0))
	if strength <= 0.0:
		return
	var pos: Vector2 = Vector2(_data[base + 0], _data[base + 1])
	var tid: int = int(cold.get("homing_target_id", -1))
	var tpos: Vector2 = Vector2(-9999.0, -9999.0)
	if tid >= 0:
		tpos = EnemyManager.get_pos_by_id(tid)
	if tpos == Vector2(-9999.0, -9999.0):
		# 目标失效或首帧：重选。空场守卫避免清场瞬间全体子弹集体 query_circle
		if EnemyManager.get_active_count() == 0:
			return
		var now: int = Time.get_ticks_msec()
		if now < int(cold.get("homing_retry_at", 0)):
			return                                  # 上次重选失败，退避中：本帧直行，不查询
		tid = _find_nearest_unvisited(pos, float(cold.get("homing_range", 400.0)), cold.get("visited_targets", []))
		cold["homing_target_id"] = tid
		_bullet_contexts[bullet_idx] = cold
		if tid < 0:
			# 抖动「到期时刻」而非让所有子弹共用固定 HOMING_RETRY_MS：同帧集体失败的子弹
			# 拿到同一个 now，退避期完全一致，12 帧后又整齐地一起重试 —— 退避会同步而非
			# 打散，同波齐射天然同相故尖峰永不消散（实测 1500 弹 visited 场景 40 帧里 4 帧
			# 达 22.5ms，直接击穿 16.67ms 帧预算）。
			# 用「bullet_idx 在活跃弹数中的占比」而非 bullet_idx % HOMING_RETRY_MS：后者在
			# 弹数少于窗口宽度时只铺开 _active_count 毫秒（20 颗弹 → 20ms ≈ 1.2 帧，等于没
			# 打散），占比式则不论弹数多少都铺满整窗。到期时刻落在 [200, 400) ms。
			# 整数除法是有意的：抖动只需毫秒粒度的偏移，小数部分丢弃即所需语义
			@warning_ignore("integer_division")
			var jitter: int = (bullet_idx * HOMING_RETRY_MS) / maxi(1, _active_count)
			# 取模在循环内不可达（bullet_idx < _active_count ⟹ jitter ≤ 199），仅作索引越界兜底
			cold["homing_retry_at"] = now + HOMING_RETRY_MS + jitter % HOMING_RETRY_MS
			return
		cold.erase("homing_retry_at")
		tpos = EnemyManager.get_pos_by_id(tid)
		if tpos == Vector2(-9999.0, -9999.0):
			return
	var vel: Vector2 = Vector2(_data[base + 2], _data[base + 3])
	var diff: float = wrapf((tpos - pos).angle() - vel.angle(), -PI, PI)   # 最短转向方向
	var max_step: float = strength * delta
	var nv: Vector2 = vel.rotated(clampf(diff, -max_step, max_step))       # rotated 保持速率不变
	_data[base + 2] = nv.x
	_data[base + 3] = nv.y

func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
	return EnemyManager.get_nearest_pos(origin, max_dist)

func reset() -> void:
	_active_count = 0
	_data.fill(0.0)
	_bullet_contexts.clear()
