/*
 * This file is part of Hawk Anticheat.
 * Copyright (C) 2018 Hawk Development Team
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package me.islandscout.hawk.check.tick;

import me.islandscout.hawk.HawkPlayer;
import me.islandscout.hawk.check.MovementCheck;
import me.islandscout.hawk.event.MoveEvent;
import me.islandscout.hawk.util.MathPlus;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerTeleportEvent;

import java.util.*;

/**
 * The Tickrate check enforces a limit to clients' tickrates.
 * Minecraft runs at 20 TPS, and some of the server logic for
 * each player is dependent on their PacketPlayInFlying rates
 * (eg: health regeneration, status effect, item consuming
 * duration). Hackers can exploit this by modifying the tickrate
 * of their client, thus creating cheats such as "instant"
 * regeneration or eating, movement speed, "lag-switching",
 * anti-potion, etc. Tickrate just alerts and prevents this.
 * It can detect at least a 3% increase in tickrate frequency
 * almost immediately under default settings.
 */
public class TickRate extends MovementCheck implements Listener {

    private final Map<UUID, Long> prevNanoTime;
    private final Map<UUID, Long> clockDrift;
    private final Map<UUID, Long> lastBigTeleportTime;
    private final boolean DEBUG;
    private final double THRESHOLD;
    private final long MAX_CATCHUP_TIME;
    private final double MIN_RATE;
    private final double MAX_RATE;
    private final boolean RUBBERBAND;
    private final boolean RESET_DRIFT_ON_FAIL; //TODO this should reset to a point 50ms from the threshold point.
    private final int WARM_UP;

    public TickRate() {
        super("tickrate", true, 10, 50, 0.995, 10000, "%player% failed tickrate. VL: %vl%, ping: %ping%, TPS: %tps%", null);
        prevNanoTime = new HashMap<>();
        clockDrift = new HashMap<>();
        lastBigTeleportTime = new HashMap<>();
        THRESHOLD = -(int) customSetting("clockDriftThresholdMillis", "", 50);
        MAX_CATCHUP_TIME = 1000000 * (int) customSetting("maxCatchupTimeMillis", "", 1000);
        DEBUG = (boolean) customSetting("debug", "", false);
        MIN_RATE = (50 - (50 / (double)customSetting("minRateMultiplier", "", 0.995))) / 1E-6;
        MAX_RATE = (50 - (50 / (double)customSetting("maxRateMultiplier", "", 1.005))) / 1E-6;
        RUBBERBAND = (boolean)customSetting("rubberband", "", true);
        RESET_DRIFT_ON_FAIL = (boolean)customSetting("resetDriftOnFail", "", false);
        WARM_UP = (int)customSetting("ignoreTicksAfterLongTeleport", "", 150) - 1;
    }

    @Override
    protected void check(MoveEvent event) {
        Player p = event.getPlayer();
        HawkPlayer pp = event.getHawkPlayer();

        long time = System.nanoTime();
        if (!prevNanoTime.containsKey(p.getUniqueId())) {
            prevNanoTime.put(p.getUniqueId(), time);
            return;
        }
        long timeElapsed = time - prevNanoTime.get(p.getUniqueId());
        prevNanoTime.put(p.getUniqueId(), time);

        if (event.hasTeleported() || pp.getCurrentTick() - lastBigTeleportTime.getOrDefault(p.getUniqueId(), 0L) < WARM_UP) {
            if(DEBUG)
                p.sendMessage(ChatColor.GRAY + "Tickrate check warming up. Please wait a moment...");
            clockDrift.put(p.getUniqueId(), 50000000L);
            return;
        }

        //if drift > 0, then player's clock is behind
        //if drift < 0, then player's clock is ahead
        //Debug messages have drift multiplied by -1 so,
        //drift > 0: clock is ahead
        //drift < 0: clock is behind
        long drift = clockDrift.getOrDefault(p.getUniqueId(), 0L);
        long delta = timeElapsed - 50000000L;
        drift += delta;
        if (drift > MAX_CATCHUP_TIME)
            drift = MAX_CATCHUP_TIME;
        if (DEBUG) {
            double msOffset = drift * 1E-6;
            p.sendMessage((msOffset < 0 ? (msOffset < THRESHOLD ? ChatColor.RED : ChatColor.YELLOW) : ChatColor.BLUE) + "CLOCK DRIFT: " + MathPlus.round(-msOffset, 2) + "ms");
        }
        if (drift * 1E-6 < THRESHOLD) {
            if(RUBBERBAND && pp.getCurrentTick() - pp.getLastTeleportSendTick() > 20) //Don't rubberband so often. You're already cancelling a ton of moves.
                punishAndTryRubberband(pp, event);
            else
                punish(pp, true, event);
            if(RESET_DRIFT_ON_FAIL)
                drift = 0;
        } else
            reward(pp);
        if (drift < 0)
            drift = (long) Math.min(0, drift + MAX_RATE);
        else
            drift = (long) Math.max(0, drift + MIN_RATE);
        clockDrift.put(p.getUniqueId(), drift);
    }

    @Override
    public void removeData(Player p) {
        prevNanoTime.remove(p.getUniqueId());
        clockDrift.remove(p.getUniqueId());
        lastBigTeleportTime.remove(p.getUniqueId());
    }

    @EventHandler(priority = EventPriority.MONITOR)
    public void onWorldChange(PlayerChangedWorldEvent e) {
        lastBigTeleportTime.put(e.getPlayer().getUniqueId(), hawk.getHawkPlayer(e.getPlayer()).getCurrentTick());
    }

    @EventHandler(priority = EventPriority.MONITOR)
    public void onTeleport(PlayerTeleportEvent e) {
        Location loc = e.getTo();
        if(!loc.getWorld().isChunkLoaded(loc.getBlockX() >> 4, loc.getBlockZ() >> 4)) {
            lastBigTeleportTime.put(e.getPlayer().getUniqueId(), hawk.getHawkPlayer(e.getPlayer()).getCurrentTick());
        }
    }
}