## WeaponServer — Server-authoritative weapon logic.
##
## Manages per-player, per-weapon runtime state: fire rate cooldowns,
## magazine + reserve ammo, reload state machine, and hit-scan raycasting.
##
## This is the authoritative version; clients predict locally via
## WeaponManager and reconcile with server confirmations.
##
## Usage (add as child of GameServer or any server-side node):
##
##   var ws = WeaponServer.new()
##   add_child(ws)
##   ws.initialise()
##   ...
##   if ws.can_fire(player_id, weapon_id):
##       var result = ws.fire(player_id, weapon_id, origin, direction)
##       if result.hit:
##           apply_damage(result)
##
class_name WeaponServer
extends Node

# Preload dependencies for headless compat
const _wd = preload("res://scripts/combat/weapon_definitions.gd")

# ---------------------------------------------------------------------------
# State shape
# ---------------------------------------------------------------------------
# _state[player_id][weapon_id] = {
#     ammo: int,                    # rounds left in magazine
#     reserve: int,                 # rounds in reserve (total reserve)
#     is_reloading: bool,           # currently in reload animation
#     reload_remaining: float,      # seconds left on current reload
#     last_fire_time: float,        # time of last shot (cooldown check)
# }

# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------

## Per-player, per-weapon runtime state.
## Accessed via _get_state() / _ensure_state() helpers.
var _state: Dictionary = {}

## Reference to the SceneTree's physics world for raycasting.
## Set automatically if the node enters the tree; can also be injected.
var physics_world: World3D = null

## Optional LagCompensation controller for server-side rewinding.
## When set, _perform_hitscan uses lag-compensated raycasting;
## otherwise it uses the current-frame physics world directly.
var lag_compensation = null

# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------

func _ready() -> void:
    if physics_world == null:
        physics_world = get_world_3d()

## Public initialiser (call after adding to tree, or inject a World3D).
func initialise(world: World3D = null) -> void:
    if world != null:
        physics_world = world
    elif physics_world == null:
        physics_world = get_world_3d()

# ---------------------------------------------------------------------------
# Per-player state management
# ---------------------------------------------------------------------------

## Ensure a player has an entry in the state dictionary.
## Called automatically by can_fire / fire.
func register_player(player_id: int) -> void:
    if not _state.has(player_id):
        _state[player_id] = {}

## Remove a player's state (on disconnect / respawn).
func unregister_player(player_id: int) -> void:
    _state.erase(player_id)

## Give a player a weapon — allocates initial ammo for it.
## Also calls register_player implicitly.
func give_weapon(player_id: int, weapon_id: String) -> void:
    register_player(player_id)
    var data := _wd.get_weapon(weapon_id)
    if data == null:
        push_warning("WeaponServer: unknown weapon '%s'" % weapon_id)
        return
    var ws: Dictionary = _state[player_id]
    ws[weapon_id] = {
        ammo = data.mag_size,
        reserve = data.mag_size * 3,  # 3 spare magazines
        is_reloading = false,
        reload_remaining = 0.0,
        last_fire_time = -INF,
    }

## Return the per-weapon state dictionary for a player+weapon, or null.
func get_weapon_state(player_id: int, weapon_id: String) -> Dictionary:
    var ps: Dictionary = _state.get(player_id, {})
    return ps.get(weapon_id, {}) as Dictionary

## Return the current ammo and reserve for a player's weapon.
## Returns {ammo: int, reserve: int, max_ammo: int} or null.
func get_ammo_info(player_id: int, weapon_id: String) -> Dictionary:
    var st := get_weapon_state(player_id, weapon_id)
    if st.is_empty():
        return {}
    var data := _wd.get_weapon(weapon_id)
    return {
        ammo = st.get("ammo", 0),
        reserve = st.get("reserve", 0),
        max_ammo = data.mag_size if data else 0,
    }

# ---------------------------------------------------------------------------
# Can-fire check
# ---------------------------------------------------------------------------

## Returns true if the player can fire the specified weapon right now.
## Checks: weapon known, ammo available, not reloading, cooldown elapsed.
func can_fire(player_id: int, weapon_id: String) -> bool:
    var data := _wd.get_weapon(weapon_id)
    if data == null:
        return false

    var st := get_weapon_state(player_id, weapon_id)
    if st.is_empty():
        # Player doesn't have this weapon yet — give it to them
        give_weapon(player_id, weapon_id)
        st = get_weapon_state(player_id, weapon_id)

    # Check reload
    if st.get("is_reloading", false):
        return false

    # Check ammo
    if st.get("ammo", 0) <= 0:
        return false

    # Check fire rate cooldown
    var fire_interval: float = 1.0 / max(data.fire_rate, 0.001)
    var last_fire: float = st.get("last_fire_time", -INF)
    var now: float = Time.get_unix_time_from_system()

    if now - last_fire < fire_interval:
        return false

    return true

# ---------------------------------------------------------------------------
# Reload
# ---------------------------------------------------------------------------

## Start reloading the player's weapon. Returns true if reload initiated.
func start_reload(player_id: int, weapon_id: String) -> bool:
    var data := _wd.get_weapon(weapon_id)
    if data == null:
        return false

    var st := get_weapon_state(player_id, weapon_id)
    if st.is_empty():
        return false

    # Already reloading
    if st.get("is_reloading", false):
        return false

    # Magazine already full
    if st.get("ammo", 0) >= data.mag_size:
        return false

    # No reserve ammo
    if st.get("reserve", 0) <= 0:
        return false

    st["is_reloading"] = true
    st["reload_remaining"] = data.reload_time
    return true

## Process reload timers. Called each server tick with delta.
## Returns true if any reload completed this tick.
func process_reloads(player_id: int, delta: float) -> bool:
    var any_completed: bool = false
    var ps: Dictionary = _state.get(player_id, {})
    for weapon_id in ps.keys():
        var st: Dictionary = ps[weapon_id]
        if not st.get("is_reloading", false):
            continue
        var remaining: float = st.get("reload_remaining", 0.0) - delta
        if remaining <= 0.0:
            # Reload complete
            _complete_reload(player_id, weapon_id, st)
            any_completed = true
        else:
            st["reload_remaining"] = remaining
    return any_completed

func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void:
    var data := _wd.get_weapon(weapon_id)
    if data == null:
        return

    var needed: int = data.mag_size - st.get("ammo", 0)
    var from_reserve: int = mini(needed, st.get("reserve", 0))

    st["ammo"] = st.get("ammo", 0) + from_reserve
    st["reserve"] = st.get("reserve", 0) - from_reserve
    st["is_reloading"] = false
    st["reload_remaining"] = 0.0

# ---------------------------------------------------------------------------
# Fire
# ---------------------------------------------------------------------------

## Fire the weapon from the given origin along direction.
## Performs a hit-scan raycast through the physics world with optional
## server-side lag compensation rewinding.
##
## tick: server tick when the shot occurred (used for lag compensation).
##       Pass -1 to skip lag compensation and use current-frame physics.
## hit_result = {
##     hit: bool,
##     position: Vector3,    # world-space hit point
##     target_id: int,       # entity/node ID if hitting a collider, -1 otherwise
##     damage: float,        # total damage dealt this shot
##     weapon_id: String,    # the weapon used
## }
##
func fire(tick: int, player_id: int, weapon_id: String, origin: Vector3, direction: Vector3) -> Dictionary:
    var data := _wd.get_weapon(weapon_id)
    if data == null:
        return _miss_result(weapon_id)

    var st := get_weapon_state(player_id, weapon_id)
    if st.is_empty():
        return _miss_result(weapon_id)

    # Authoritative can-fire check (belt and suspenders)
    if not _hard_can_fire(player_id, weapon_id, data, st):
        return _miss_result(weapon_id)

    # Deduct ammo
    st["ammo"] = st.get("ammo", 1) - 1
    st["last_fire_time"] = Time.get_unix_time_from_system()

    # Auto-reload if magazine empty
    if st["ammo"] <= 0 and st.get("reserve", 0) > 0:
        st["is_reloading"] = true
        st["reload_remaining"] = data.reload_time

    # Perform hit-scan — supports multi-pellet weapons
    return _perform_hitscan(tick, player_id, weapon_id, data, origin, direction, st)

# ---------------------------------------------------------------------------
# Internal: hitscan
# ---------------------------------------------------------------------------

func _perform_hitscan(
    tick: int,
    player_id: int,
    weapon_id: String,
    data: WeaponData,
    origin: Vector3,
    base_direction: Vector3,
    st: Dictionary
) -> Dictionary:
    var space_state: PhysicsDirectSpaceState3D = _get_space_state()
    if space_state == null:
        return _miss_result(weapon_id)

    var total_damage: float = 0.0
    var latest_hit_pos: Vector3 = Vector3.ZERO
    var latest_target_id: int = -1
    var any_hit: bool = false

    var pellets: int = max(1, data.pellets_per_shot)
    var max_range: float = max(1.0, data.range)
    var spread_rad: float = deg_to_rad(data.spread_degrees)

    # Build a collision exception list so we don't hit the shooter
    var exclude: Array[RID] = _get_shooter_exclusions(player_id)

    for i in range(pellets):
        var dir: Vector3 = _apply_spread(base_direction, spread_rad)

        # --- Lag-compensated raycast ---
        # If lag_compensation is available and a valid tick is provided,
        # rewind to the target tick before raycasting. Otherwise fall
        # back to the current-frame physics world.
        var result: Dictionary = {}
        if lag_compensation != null and tick >= 0:
            result = lag_compensation.rewind_and_raycast(
                tick, origin, dir, max_range, exclude
            )
        else:
            # Fallback: no lag compensation — raycast against current positions
            var query := PhysicsRayQueryParameters3D.create(
                origin, origin + dir * max_range
            )
            query.exclude = exclude
            query.collide_with_bodies = true
            query.collide_with_areas = false
            result = space_state.intersect_ray(query)

        if result.is_empty():
            continue

        any_hit = true
        latest_hit_pos = result.get("position", Vector3.ZERO)
        var collider: Object = result.get("collider", null)
        if collider and collider.has_method(&"get_entity_id"):
            latest_target_id = collider.get_entity_id()
        elif collider:
            latest_target_id = collider.get_instance_id()

        total_damage += data.damage

    if not any_hit:
        return _miss_result(weapon_id)

    return {
        hit = true,
        position = latest_hit_pos,
        target_id = latest_target_id,
        damage = total_damage,
        weapon_id = weapon_id,
    }

func _miss_result(weapon_id: String) -> Dictionary:
    return {
        hit = false,
        position = Vector3.ZERO,
        target_id = -1,
        damage = 0.0,
        weapon_id = weapon_id,
    }

# ---------------------------------------------------------------------------
# Internal: helpers
# ---------------------------------------------------------------------------

## Strict can-fire that does NOT auto-grant the weapon.
func _hard_can_fire(
    player_id: int,
    weapon_id: String,
    data: WeaponData,
    st: Dictionary
) -> bool:
    if st.is_empty():
        return false
    if st.get("is_reloading", false):
        return false
    if st.get("ammo", 0) <= 0:
        return false

    var fire_interval: float = 1.0 / max(data.fire_rate, 0.001)
    var last_fire: float = st.get("last_fire_time", -INF)
    var now: float = Time.get_unix_time_from_system()
    if now - last_fire < fire_interval:
        return false
    return true

func _get_space_state() -> PhysicsDirectSpaceState3D:
    if physics_world != null:
        return physics_world.direct_space_state
    # Fallback: try from the scene tree
    var w: World3D = get_world_3d()
    if w != null:
        physics_world = w
        return w.direct_space_state
    return null

func _get_shooter_exclusions(player_id: int) -> Array[RID]:
    # Try to find the player's collision body via the entity system
    # For now, return an empty array (no self-exclusion beyond what Godot does)
    return []

## Apply spread to a base direction vector.
func _apply_spread(base: Vector3, spread_rad: float) -> Vector3:
    if spread_rad <= 0.001:
        return base
    var theta: float = randf() * TAU
    var phi: float = randf() * spread_rad
    var up := Vector3.UP
    if abs(base.dot(up)) > 0.99:
        up = Vector3.RIGHT
    var right := base.cross(up).normalized()
    up = right.cross(base).normalized()
    var offset := right * sin(theta) * sin(phi) + up * cos(theta) * sin(phi)
    return (base + offset).normalized()
