## AntiCheat — Server-side anti-cheat controller.
##
## Provides basic integrity checks against obvious exploits:
##   - Rate limiting   (input flood / RPC spam above 130 Hz)
##   - Speed hacks     (position deltas exceeding max movement × 1.5)
##   - Aimbot snap     (look-direction changes >45° within one tick)
##
## Suspicion accumulates per player (0.0 – 1.0). Escalating actions:
##   0.3 → warn  (server log)
##   0.6 → kick  (disconnect)
##   0.8 → ban   (temp ban, 1 hour)
##
## Design philosophy:
##   - All checks use generous tolerances (1.5× – 2× expected max) to account
##     for network jitter and edge cases.
##   - When in doubt, do NOT flag. False-positive rate must be near zero.
##   - This is Phase-4 basic hardening, NOT a competitive anti-cheat.
##
## Usage (inside GameServer._ready()):
##   var ac = AntiCheat.new()
##   add_child(ac)
##   ac.set_max_speed(ServerConfig.movement_sprint_speed)
##   # Wire a ban handler:
##   ac.ban_handler = func(p_id, reason):
##       multiplayer.disconnect_peer(p_id)
##
## Receive reports from gameplay systems:
##   ac.report_input_rate(player_id, 150.0)
##   ac.report_movement(player_id, Vector3(12, 0, 0), 1)
##   ac.report_look_snap(player_id, from_dir, to_dir)

class_name AntiCheat
extends Node

# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------

## Emitted when a player's suspicion level changes.
signal player_suspicion_changed(player_id: int, level: float)

## Emitted when a player is kicked for anti-cheat violations.
## The caller (GameServer) should disconnect the peer.
signal player_kicked(player_id: int, reason: String)

## Emitted when a player is temp-banned for anti-cheat violations.
## The caller should enforce the ban (disconnect + track expiry).
signal player_banned(player_id: int, reason: String)

# ---------------------------------------------------------------------------
# Constants — thresholds
# ---------------------------------------------------------------------------

## Maximum input RPC rate (Hz). Margin above the 128 Hz sim tick.
const DEFAULT_RATE_LIMIT_HZ: float = 130.0

## Tolerance multiplier applied to expected max movement speed.
const SPEED_TOLERANCE: float = 1.5

## Maximum allowed look-angle change per tick, in degrees.
const AIMBOT_SNAP_DEG: float = 45.0

## Default maximum player speed (sprint speed from default config).
const DEFAULT_MAX_SPEED: float = 7.0

# ---------------------------------------------------------------------------
# Constants — suspicion values
# ---------------------------------------------------------------------------

## Suspicion added per rate-limit violation.
const SUS_INPUT_RATE: float = 0.08

## Suspicion added per speed-hack violation.
const SUS_SPEED_HACK: float = 0.12

## Suspicion added per aimbot snap.
const SUS_AIMBOT: float = 0.10

# ---------------------------------------------------------------------------
# Constants — thresholds & timing
# ---------------------------------------------------------------------------

const WARN_THRESHOLD: float = 0.3
const KICK_THRESHOLD: float = 0.6
const BAN_THRESHOLD: float = 0.8

## Suspicion decay amount per DECAY_INTERVAL seconds (rewards clean play).
const DECAY_AMOUNT: float = 0.02

## How often (seconds) suspicion decays for clean players.
const DECAY_INTERVAL: float = 2.0

## Temporary ban duration in seconds (1 hour).
const BAN_DURATION: float = 3600.0

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

## Max player speed in units/sec. Set via set_max_speed() to match server
## config. Defaults to DEFAULT_MAX_SPEED.
var _max_speed: float = DEFAULT_MAX_SPEED

## Current suspicion per player, 0.0 – 1.0.
var _suspicion: Dictionary = {}  # player_id (int) → level (float)

## Tracks which players have already triggered each action level.
var _warned: Dictionary = {}  # player_id (int) → true
var _kicked: Dictionary = {}  # player_id (int) → true
var _banned: Dictionary = {}  # player_id (int) → ban_expiry_time (float)

## Accumulator for suspicion decay timer.
var _decay_timer: float = 0.0

## Optional kick/ban handler Callable. If set, it is invoked with
## (player_id, reason) when a player is kicked or banned. The caller is
## responsible for actually disconnecting or tracking the ban.
var ban_handler: Callable = Callable()

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

func _ready() -> void:
    pass

func _process(delta: float) -> void:
    _decay_timer += delta
    if _decay_timer >= DECAY_INTERVAL:
        _decay_timer = 0.0
        _decay_suspicion()

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

## Set the max player speed (should match the server's sprint speed or
## the fastest any legitimate player can move).
func set_max_speed(speed: float) -> void:
    _max_speed = max(0.1, speed)

## Return the current max-speed value used for speed-hack checks.
func get_max_speed() -> float:
    return _max_speed

# ---------------------------------------------------------------------------
# Public API — report methods
# ---------------------------------------------------------------------------

## Report a player's input RPC rate. Flags if rate_hz exceeds the limit.
func report_input_rate(player_id: int, rate_hz: float) -> void:
    if _is_banned(player_id):
        return

    if rate_hz > DEFAULT_RATE_LIMIT_HZ:
        _add_suspicion(
            player_id,
            SUS_INPUT_RATE,
            "Input rate %.1f Hz exceeds limit (max %.1f Hz)" % [rate_hz, DEFAULT_RATE_LIMIT_HZ]
        )


## Report a player's position delta over a given number of ticks.
## delta_pos: the Vector3 displacement since the last valid report.
## tick_delta: number of simulation ticks over which the movement occurred
##             (typically 1 for per-tick checks, larger for infrequent reports).
func report_movement(player_id: int, delta_pos: Vector3, tick_delta: int) -> void:
    if _is_banned(player_id):
        return

    if tick_delta <= 0:
        return

    var distance: float = delta_pos.length()
    var tick_time: float = 1.0 / 128.0  # simulation tick duration
    ## Max distance a player could legitimately travel in tick_delta ticks.
    var max_expected: float = _max_speed * SPEED_TOLERANCE * tick_time * tick_delta

    if distance > max_expected:
        _add_suspicion(
            player_id,
            SUS_SPEED_HACK,
            "Movement %.2f units exceeds expected max %.2f (%d ticks)" %
                [distance, max_expected, tick_delta]
        )


## Report a player's look-direction snap.
## from: the previous tick's look direction (normalised Vector3).
## to:   the current tick's look direction (normalised Vector3).
## Flags if the angle between them exceeds AIMBOT_SNAP_DEG.
func report_look_snap(player_id: int, from: Vector3, to: Vector3) -> void:
    if _is_banned(player_id):
        return

    # Avoid normalisation issues with zero vectors
    var f_len_sq: float = from.length_squared()
    var t_len_sq: float = to.length_squared()
    if f_len_sq < 0.0001 or t_len_sq < 0.0001:
        return

    var dot: float = from.dot(to) / (sqrt(f_len_sq) * sqrt(t_len_sq))
    var angle_deg: float = rad_to_deg(acos(clampf(dot, -1.0, 1.0)))

    if angle_deg > AIMBOT_SNAP_DEG:
        _add_suspicion(
            player_id,
            SUS_AIMBOT,
            "Look snap %.1f° exceeds max %.1f°" % [angle_deg, AIMBOT_SNAP_DEG]
        )

# ---------------------------------------------------------------------------
# Public API — state queries
# ---------------------------------------------------------------------------

## Return the current suspicion level for a player (0.0 – 1.0).
func get_suspicion(player_id: int) -> float:
    return _suspicion.get(player_id, 0.0)

## Return true if the player is currently banned (ban still in effect).
func is_banned(player_id: int) -> bool:
    return _is_banned(player_id)

## Remove all tracking data for a player (on disconnect / map change).
func unregister_player(player_id: int) -> void:
    _suspicion.erase(player_id)
    _warned.erase(player_id)
    _kicked.erase(player_id)
    # NOTE: _banned entries persist so reconnecting within the ban window
    # still rejects. Use clear_bans() on map change or explicit unban.

## Clear all ban records (e.g., on server restart or map change).
func clear_bans() -> void:
    _banned.clear()

## Clear all suspicion tracking (full reset).
func reset_all() -> void:
    _suspicion.clear()
    _warned.clear()
    _kicked.clear()
    _banned.clear()
    _decay_timer = 0.0

# ---------------------------------------------------------------------------
# Internal — suspicion management
# ---------------------------------------------------------------------------

## Add suspicion for a player and take action if thresholds are crossed.
## reason is a human-readable string for log/kick/ban messages.
func _add_suspicion(player_id: int, amount: float, reason: String) -> void:
    var current: float = _suspicion.get(player_id, 0.0)
    current = min(1.0, current + amount)
    _suspicion[player_id] = current

    player_suspicion_changed.emit(player_id, current)

    # Check thresholds in ascending order — only act on the highest newly
    # reached threshold to avoid spam (no re-warn on same violation).
    if current >= BAN_THRESHOLD and not _banned.has(player_id):
        _apply_ban(player_id, reason)
    elif current >= KICK_THRESHOLD and not _kicked.has(player_id):
        _apply_kick(player_id, reason)
    elif current >= WARN_THRESHOLD and not _warned.has(player_id):
        _apply_warn(player_id, reason)


func _apply_warn(player_id: int, reason: String) -> void:
    _warned[player_id] = true
    var msg: String = "[AntiCheat] WARN — player %d: %s" % [player_id, reason]
    print(msg)
    # No disconnect; just a server-side log warning.


func _apply_kick(player_id: int, reason: String) -> void:
    _kicked[player_id] = true
    var msg: String = "[AntiCheat] KICK — player %d: %s" % [player_id, reason]
    print(msg)
    player_kicked.emit(player_id, reason)

    if ban_handler.is_valid():
        ban_handler.call(player_id, reason)


func _apply_ban(player_id: int, reason: String) -> void:
    var expiry: float = Time.get_unix_time_from_system() + BAN_DURATION
    _banned[player_id] = expiry
    var msg: String = "[AntiCheat] BAN — player %d: %s (until UNIX %d)" % \
        [player_id, reason, int(expiry)]
    print(msg)
    player_banned.emit(player_id, reason)

    if ban_handler.is_valid():
        ban_handler.call(player_id, reason)


## Decay suspicion for all non-banned players.
func _decay_suspicion() -> void:
    for player_id in _suspicion.keys():
        if _is_banned(player_id):
            continue
        var current: float = _suspicion[player_id] - DECAY_AMOUNT
        if current <= 0.0:
            _suspicion.erase(player_id)
            _warned.erase(player_id)
            # Keep _kicked so they don't get a second chance immediately
            # after decay; only explicit unregister resets kicks.
        else:
            _suspicion[player_id] = current
            player_suspicion_changed.emit(player_id, current)


## Check if a player is currently banned (ban recorded and still in effect).
func _is_banned(player_id: int) -> bool:
    if not _banned.has(player_id):
        return false
    var expiry: float = _banned[player_id]
    if Time.get_unix_time_from_system() >= expiry:
        # Ban expired — clean up
        _banned.erase(player_id)
        return false
    return true
