## PlayerStats — 玩家属性统计（Autoload: PlayerStats）
## 权威来源：development_plan.md S2、numerical_design.md §1.2
extends Node

signal stats_changed
signal leveled_up(new_level: int)

# ── 数据源 ────────────────────────────────────────────
const ATTRIBUTES_JSON: String = "res://data/attributes.json"

## 法杖运算力份额的加成来源标识（combat_manager._rebuild_wand / combat_test 注入时使用）
## 必须用常量而非裸字面量："core" 在 remove/add 两侧必须逐字相同——remove 那侧打错一个字符，
## 旧份额就不会被撤销而是逐次累积，且 cpu_limit 的 hard(50) 会把累积值封成一个看起来合理的
## 数字（不是显眼的荒谬值），故障零诊断且极难发现。常量把这个风险变成编译期错误。
const MOD_SOURCE_CORE: String = "core"

# ── HP ───────────────────────────────────────────────
var hp:     float = 100.0
var hp_max: float = 100.0

# ── 资源 ──────────────────────────────────────────────
var gold:   int   = 0
var xp:     int   = 0

# ── 等级 ──────────────────────────────────────────────
var level:     int = 1
var xp_to_next: int = 10  # 升级所需 XP

# ── 属性（生效值：静态类型裸字段，读取端零开销）─────────────
# 由 _recompute_attrs() 经 AttributeFormula 从 base + _modifiers 算出，勿直接赋值
var cpu_limit:      int   = 0     # 生效运算力；MAX_OPS = 本值 × 40
                                  # 法杖份额待 combat_manager 以 source="core" 注入（Task 3 接线后）
var move_speed:     float = 200.0 # 像素/秒
var cast_delay_mod: float = 1.0   # 施法间隔乘算系数，越低越快
var _invuln_until_msec: int = 0   # < now 表示可受击；受击后设为 now + iframe 窗口

# 属性定义与加成来源（非热路径）
var _attr_def:  Dictionary = {}          # attributes.json 全量定义，只读
var _modifiers: Array[Dictionary] = []   # [{attr_id, mode, value, source}]

# ── 魔力（MVP）─────────────────────────────────────────
var mana:       float = 100.0
var mana_max:   float = 100.0
var mana_regen: float = 5.0    # 每秒回复
var mana_heat:  float = 0.0    # 递增蓝耗热值（0=无，1=蓝耗翻倍）
var mana_leech: float = 0.0    # 击杀回蓝量（换杖/换牌重算）

func _ready() -> void:
	_load_attr_definitions()
	_recompute_attrs()
	EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)

func _on_enemy_killed(_payload: Dictionary) -> void:
	gain_mana(mana_leech)

## 升级曲线：roundf(10 × 1.4^(level-1))，P6-N16
## Level 1→2: 10 XP; Level 5→6: ≈ 54 XP
static func xp_for_level(lv: int) -> int:
	return roundi(10.0 * pow(1.4, float(lv - 1)))

func gain_xp(amount: int) -> void:
	xp += amount
	while xp >= xp_to_next:
		xp -= xp_to_next
		level += 1
		xp_to_next = xp_for_level(level)
		leveled_up.emit(level)
		EventBus.emit(EventID.LEVEL_UP, {"level": level})
	stats_changed.emit()

func gain_gold(amount: int) -> void:
	gold += amount
	stats_changed.emit()

func spend_gold(amount: int) -> bool:
	if gold < amount:
		return false
	gold -= amount
	stats_changed.emit()
	return true

func take_damage(amount: float) -> void:
	var now: int = Time.get_ticks_msec()
	if now < _invuln_until_msec:
		return  # 无敌窗口内：忽略外部伤害（多弹同帧只扣一次）
	var applied: float = amount * SettingsManager.player_dmg_taken_mult()  # 难度减伤（初学者×0.7）
	hp = max(0.0, hp - applied)
	_invuln_until_msec = now + int(SettingsManager.player_iframe_sec() * 1000.0)
	EventBus.emit(EventID.PLAYER_DAMAGED, {"damage": applied})  # 复活 hurt 音效 / HUD 钩子
	stats_changed.emit()
	if hp <= 0.0:
		EventBus.emit(EventID.PLAYER_DIED, {})

func is_invulnerable() -> bool:
	return Time.get_ticks_msec() < _invuln_until_msec

func heal(amount: float) -> void:
	hp = min(hp_max, hp + amount)
	stats_changed.emit()

func get_hp_percent() -> float:
	return hp / max(hp_max, 0.001)

## 换杖时设定蓝池上限/回速，并把当前蓝夹到 [0, max]
func set_mana_pool(max_v: float, regen: float) -> void:
	mana_max   = max_v
	mana_regen = regen
	mana       = clampf(mana, 0.0, mana_max)
	stats_changed.emit()

## 花费魔力；够则扣除返回 true，否则不变返回 false
func spend_mana(cost: float) -> bool:
	if mana < cost:
		return false
	mana -= cost
	stats_changed.emit()
	return true

## 每帧回蓝（PlayerManager 驱动）。不 emit stats_changed —— HUD 每帧直接读 mana，
## 避免回蓝过程每帧发信号触发商店刷新。
func regen_mana(delta: float) -> void:
	if mana >= mana_max:
		return
	mana = min(mana_max, mana + mana_regen * delta)

## 击杀回蓝（魔力虹吸）
func gain_mana(n: float) -> void:
	if n <= 0.0:
		return
	mana = min(mana_max, mana + n)
	stats_changed.emit()

## 主动 HP 代价（infinite_spells / heavy_cost）——直接扣血，不走难度减伤
func spend_hp_cost(amount: float) -> void:
	if amount <= 0.0:
		return
	hp = max(0.0, hp - amount)
	stats_changed.emit()
	if hp <= 0.0:
		EventBus.emit(EventID.PLAYER_DIED, {})

func reset_for_run() -> void:
	_modifiers.clear()          # 局内加成不跨局；"core" 随后由 _rebuild_wand 重建
	_recompute_attrs()          # 必须早于 hp = hp_max，否则 hp 会用陈旧的 hp_max 播种
	hp        = hp_max
	mana      = mana_max
	gold      = 0
	xp        = 0
	level     = 1
	xp_to_next = xp_for_level(1)
	_invuln_until_msec = 0
	stats_changed.emit()

## hp_max / cpu_limit 不入存档：二者现为 attributes.json + _modifiers 的派生值，
## 回读会覆盖公式结果。旧存档里的同名键忽略即可，无需提升 schema 版本。
##
## 留给货架 C：一旦玩家可购买属性，需要持久化的是 _modifiers（来源列表）而非派生的
## 生效值，届时本函数应加 "attr_modifiers": _modifiers 并提升 schema 版本。
## · source == "core" 的那条**不要**持久化——它在换杖时由 combat_manager._rebuild_wand 重建。
## · 回读须用 _modifiers.assign(...)：JSON.parse_string / data.get(..., []) 产出的是无类型
##   Array，直接 `=` 赋给 Array[Dictionary] 会运行时类型错误。
## · 回读顺序：_modifiers 必须先 assign + _recompute_attrs()，再赋 hp。反过来会让
##   _recompute_attrs 的 hp = minf(hp, hp_max) 拿未加成的 hp_max 去钳，静默吞血。
func get_save_data() -> Dictionary:
	return {"hp": hp, "gold": gold, "xp": xp, "level": level}

func load_save_data(data: Dictionary) -> void:
	hp        = float(data.get("hp",    100.0))
	gold      = int(data.get("gold",    0))
	xp        = int(data.get("xp",      0))
	level     = int(data.get("level",   1))
	xp_to_next = xp_for_level(level)
	stats_changed.emit()

# ── 属性框架 ──────────────────────────────────────────────

func _load_attr_definitions() -> void:
	if not FileAccess.file_exists(ATTRIBUTES_JSON):
		push_error("PlayerStats: 缺少 %s" % ATTRIBUTES_JSON)
		return
	var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON))
	if not (parsed is Dictionary):
		push_error("PlayerStats: %s 格式错误（应为对象）" % ATTRIBUTES_JSON)
		return
	# 逐条守卫：畸形条目（如 "move_speed": 200）会让 _compute_attr 的 Dictionary 赋值硬崩
	for k in parsed:
		if not (parsed[k] is Dictionary):
			push_error("PlayerStats: attributes.json 的「%s」应为对象，整个文件已拒绝加载" % k)
			return
	_attr_def = parsed

## 追加一条加成来源；同一 source 可对多个属性各加一条
## 本函数是 AttributeFormula.compute 的守门人——公式模块明文假定 mode/value 合法且不再校验，
## 故非法 attr_id / mode 必须在此拦下：否则错条目会永远堆在 _modifiers 里且不产生任何诊断
func add_modifier(attr_id: String, mode: String, value: float, source: String) -> void:
	if not _attr_def.has(attr_id):
		push_error("PlayerStats: 未知属性「%s」（来源 %s），加成已忽略" % [attr_id, source])
		return
	if mode != "flat" and mode != "pct":
		push_error("PlayerStats: 未知 mode「%s」（%s→%s），加成已忽略" % [mode, source, attr_id])
		return
	_modifiers.append({"attr_id": attr_id, "mode": mode, "value": value, "source": source})
	_recompute_attrs()

## 撤销某来源的全部加成（换杖、出售退款用）
func remove_modifiers_from(source: String) -> void:
	var kept: Array[Dictionary] = []
	for m in _modifiers:
		if String(m.get("source", "")) != source:
			kept.append(m)
	if kept.size() == _modifiers.size():
		return   # 无命中：不 emit stats_changed，避免无谓的 HUD / 商店刷新
	_modifiers = kept
	_recompute_attrs()

func _mods_for(attr_id: String) -> Array[Dictionary]:
	var out: Array[Dictionary] = []
	for m in _modifiers:
		if String(m.get("attr_id", "")) == attr_id:
			out.append(m)
	return out

## 逐属性按各自 combine 公式重算生效值，写回裸字段（加成增删后调用）
func _recompute_attrs() -> void:
	if _attr_def.is_empty():
		# 定义未加载，根因已由 _load_attr_definitions push_error。此处不重复报错：本函数在
		# 换杖/换牌/购买时被频繁调用，无条件报错会刷屏并埋掉那条根因。assert 在 release 被
		# 编译掉，开发期则由 _ready 的首次调用立即中断。
		assert(false, "PlayerStats: 属性定义未加载，add_modifier / 重算全部失效")
		return
	cpu_limit      = int(_compute_attr("cpu_limit"))
	move_speed     = _compute_attr("move_speed")
	cast_delay_mod = _compute_attr("cast_delay_mod")
	hp_max         = _compute_attr("hp_max")
	hp             = minf(hp, hp_max)  # hp_max 下调（出售退款/换杖）时避免 hp > hp_max
	stats_changed.emit()

func _compute_attr(attr_id: String) -> float:
	var d: Dictionary = _attr_def.get(attr_id, {})
	if d.is_empty():
		# 返回 0.0 的后果按属性而异，但都是「莫名其妙的一块砖」：缺 cast_delay_mod → 生效间隔 0
		# → 每物理帧施法一次；缺 move_speed → 玩家不能动；缺 hp_max → 进场即死。
		# Task 4 已硬化生产侧（attribute_tab 的 _loaded 拒绝写出零载荷），这里是消费侧对称的那一半。
		# assert 与 _recompute_attrs 空定义分支同款：运行时 push_error 到不了任何日志通道
		#（已实测，见 plan 工具坑 ⑧），只靠它等于没有诊断；assert 在 release 被编译掉、开发期立刻中断。
		push_error("PlayerStats: attributes.json 缺少属性「%s」" % attr_id)
		assert(false, "PlayerStats: 缺少属性「%s」——生效值将为 0，运行时 push_error 不可见" % attr_id)
		return 0.0
	return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d)
