package net.citizensnpcs.npc.skin;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.UUID;
import java.util.WeakHashMap;

import javax.annotation.Nullable;

import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;

import com.google.common.base.Preconditions;
import com.google.common.base.Predicates;
import com.google.common.collect.Iterables;
import com.google.common.collect.Sets;

import net.citizensnpcs.Settings.Setting;
import net.citizensnpcs.api.CitizensAPI;
import net.citizensnpcs.api.npc.NPC;
import net.citizensnpcs.api.util.Messaging;
import net.citizensnpcs.util.Util;

/**
 * Tracks skin updates for players.
 *
 * @see net.citizensnpcs.EventListen
 */
public class SkinUpdateTracker {
    private final Map<SkinnableEntity, Void> navigating = new WeakHashMap<>(25);
    private final Map<UUID, PlayerTracker> playerTrackers = new HashMap<>(
            Math.max(128, Math.min(1024, Bukkit.getMaxPlayers() / 2)));
    private final NPCNavigationUpdater updater = new NPCNavigationUpdater();

    /**
     * Constructor.
     *
     * @param registries
     *            Map of other registries.
     */
    public SkinUpdateTracker() {
        updater.runTaskTimer(CitizensAPI.getPlugin(), 1, 1);
        new NPCNavigationTracker().runTaskTimer(CitizensAPI.getPlugin(), 3, 7);
    }

    // determines if a player is near a skinnable entity and, if checkFov set, if the
    // skinnable entity is within the player's field of view.
    private boolean canSee(Player player, SkinnableEntity skinnable, boolean checkFov) {
        Player entity = skinnable.getBukkitEntity();
        if (entity == null || !player.canSee(entity) || !player.getWorld().equals(entity.getWorld()))
            return false;

        Location playerLoc = player.getLocation();
        Location skinLoc = entity.getLocation();

        if (playerLoc.distance(skinLoc) > Setting.NPC_SKIN_VIEW_DISTANCE.asDouble())
            return false;

        // see if the NPC is within the players field of view
        if (checkFov) {
            double deltaX = skinLoc.getX() - playerLoc.getX();
            double deltaZ = skinLoc.getZ() - playerLoc.getZ();
            double angle = Math.atan2(deltaX, deltaZ);
            float skinYaw = Util.clamp(-(float) Math.toDegrees(angle));
            float playerYaw = Util.clamp(playerLoc.getYaw());
            float upperBound = Util.clamp(playerYaw + FIELD_OF_VIEW);
            float lowerBound = Util.clamp(playerYaw - FIELD_OF_VIEW);
            if (upperBound == -180.0 && playerYaw > 0) {
                upperBound = 0;
            }
            boolean hasMoved;
            if (playerYaw - 90 < -180 || playerYaw + 90 > 180) {
                hasMoved = skinYaw > lowerBound && skinYaw < upperBound;
            } else {
                hasMoved = skinYaw < lowerBound || skinYaw > upperBound;
            }
            return hasMoved;
        }
        return true;
    }

    private Iterable<NPC> getAllNPCs() {
        return Iterables.filter(Iterables.concat(CitizensAPI.getNPCRegistries()), Predicates.notNull());
    }

    private List<SkinnableEntity> getNearbyNPCs(Player player, boolean reset, boolean checkFov) {
        List<SkinnableEntity> results = new ArrayList<>();
        PlayerTracker tracker = getTracker(player, reset);
        for (NPC npc : getAllNPCs()) {
            SkinnableEntity skinnable = getSkinnable(npc);
            // if checking field of view, don't add skins that have already been updated for FOV
            if (skinnable == null || checkFov && tracker.fovVisibleSkins.contains(skinnable)) {
                continue;
            }
            if (canSee(player, skinnable, checkFov)) {
                results.add(skinnable);
            }
        }
        return results;
    }

    // get all navigating skinnable NPC's within the players FOV that have not been "seen" yet
    private void getNewVisibleNavigating(Player player, Collection<SkinnableEntity> output) {
        PlayerTracker tracker = getTracker(player, false);

        for (SkinnableEntity skinnable : navigating.keySet()) {
            // make sure player hasn't already been updated to prevent excessive tab list flashing
            // while NPC's are navigating and to reduce the number of times #canSee is invoked.
            if (tracker.fovVisibleSkins.contains(skinnable)) {
                continue;
            }
            if (canSee(player, skinnable, true)) {
                output.add(skinnable);
            }
        }
    }

    @Nullable
    private SkinnableEntity getSkinnable(NPC npc) {
        Entity entity = npc.getEntity();
        if (entity == null)
            return null;

        return entity instanceof SkinnableEntity ? (SkinnableEntity) entity : null;
    }

    // get a players tracker, create new one if not exists.
    private PlayerTracker getTracker(Player player, boolean reset) {
        PlayerTracker tracker = playerTrackers.get(player.getUniqueId());
        if (tracker == null) {
            tracker = new PlayerTracker(player);
            playerTrackers.put(player.getUniqueId(), tracker);
        } else if (reset) {
            tracker.hardReset(player);
        }
        return tracker;
    }

    /**
     * Invoke when an NPC is despawned.
     *
     * @param npc
     *            The despawned NPC.
     */
    public void onNPCDespawn(NPC npc) {
        Preconditions.checkNotNull(npc);
        playerTrackers.remove(npc.getUniqueId());
        SkinnableEntity skinnable = getSkinnable(npc);
        if (skinnable == null)
            return;

        navigating.remove(skinnable);

        for (PlayerTracker tracker : playerTrackers.values()) {
            tracker.fovVisibleSkins.remove(skinnable);
        }
    }

    /**
     * Invoke when an NPC begins navigating.
     *
     * @param npc
     *            The navigating NPC.
     */
    public void onNPCNavigationBegin(NPC npc) {
        Preconditions.checkNotNull(npc);
        SkinnableEntity skinnable = getSkinnable(npc);
        if (skinnable == null)
            return;

        navigating.put(skinnable, null);
    }

    /**
     * Invoke when an NPC finishes navigating.
     *
     * @param npc
     *            The finished NPC.
     */
    public void onNPCNavigationComplete(NPC npc) {
        Preconditions.checkNotNull(npc);
        SkinnableEntity skinnable = getSkinnable(npc);
        if (skinnable == null)
            return;

        navigating.remove(skinnable);
    }

    /**
     * Invoke when an NPC is spawned.
     *
     * @param npc
     *            The spawned NPC.
     */
    public void onNPCSpawn(NPC npc) {
        Preconditions.checkNotNull(npc);
        SkinnableEntity skinnable = getSkinnable(npc);
        if (skinnable == null)
            return;

        // reset nearby players in case they are not looking at the NPC when it spawns.
        resetNearbyPlayers(skinnable);
    }

    /**
     * Invoke when a player moves.
     *
     * @param player
     *            The player that moved.
     */
    public void onPlayerMove(Player player) {
        Preconditions.checkNotNull(player);
        PlayerTracker updateTracker = playerTrackers.get(player.getUniqueId());
        if (updateTracker == null || !updateTracker.shouldUpdate(player))
            return;

        updatePlayer(player, 10, false);
    }

    /**
     * Remove a player from the tracker.
     *
     * <p>
     * Used when the player logs out.
     * </p>
     *
     * @param playerId
     *            The ID of the player.
     */
    public void removePlayer(UUID playerId) {
        Preconditions.checkNotNull(playerId);
        playerTrackers.remove(playerId);
    }

    /**
     * Reset all players currently being tracked.
     *
     * <p>
     * Used when Citizens is reloaded.
     * </p>
     */
    public void reset() {
        navigating.clear();
        playerTrackers.clear();
    }

    // hard reset players near a skinnable NPC
    private void resetNearbyPlayers(SkinnableEntity skinnable) {
        Entity entity = skinnable.getBukkitEntity();
        if (entity == null || !entity.isValid())
            return;

        double viewDistance = Setting.NPC_SKIN_VIEW_DISTANCE.asDouble();
        Location location = entity.getLocation();
        List<Player> players = entity.getWorld().getPlayers();
        for (Player player : players) {
            if (player.hasMetadata("NPC")) {
                continue;
            }
            Location ploc = player.getLocation();
            if (ploc.getWorld() != location.getWorld() || ploc.distance(location) > viewDistance) {
                continue;
            }
            PlayerTracker tracker = playerTrackers.get(player.getUniqueId());
            if (tracker != null) {
                tracker.hardReset(player);
            }
        }
    }

    /**
     * Update a player with skin related packets from nearby skinnable NPCs.
     *
     * @param player
     *            The player to update.
     * @param delay
     *            The delay before sending the packets.
     * @param reset
     *            True to hard reset the players tracking info, otherwise false.
     */
    public void updatePlayer(Player player, long delay, boolean reset) {
        if (player.hasMetadata("NPC"))
            return;

        new BukkitRunnable() {
            @Override
            public void run() {
                List<SkinnableEntity> visible = getNearbyNPCs(player, reset, false);
                for (SkinnableEntity skinnable : visible) {
                    if (Messaging.isDebugging()) {
                        Messaging.debug("Sending skin from", skinnable.getBukkitEntity(), "to", player,
                                "(" + delay + "t delay, reset", reset + ")");
                    }
                    skinnable.getSkinTracker().updateViewer(player);
                }
            }
        }.runTaskLater(CitizensAPI.getPlugin(), delay);
    }

    // update players when the NPC navigates into their field of view
    private class NPCNavigationTracker extends BukkitRunnable {
        @Override
        public void run() {
            if (navigating.isEmpty() || playerTrackers.isEmpty())
                return;

            List<SkinnableEntity> nearby = new ArrayList<>(10);
            Set<UUID> seen = Sets.newHashSet();
            for (Player player : Bukkit.getOnlinePlayers()) {
                seen.add(player.getUniqueId());
                if (player.hasMetadata("NPC")) {
                    continue;
                }
                getNewVisibleNavigating(player, nearby);

                for (SkinnableEntity skinnable : nearby) {
                    PlayerTracker tracker = getTracker(player, false);
                    tracker.fovVisibleSkins.add(skinnable);
                    updater.queue.offer(new UpdateInfo(player, skinnable));
                }
                nearby.clear();
            }
            playerTrackers.keySet().removeIf(uuid -> !seen.contains(uuid));
        }
    }

    // Updates players. Repeating task used to schedule updates without
    // causing excessive scheduling.
    private static class NPCNavigationUpdater extends BukkitRunnable {
        Queue<UpdateInfo> queue = new ArrayDeque<>(20);

        @Override
        public void run() {
            while (!queue.isEmpty()) {
                UpdateInfo info = queue.remove();
                info.entity.getSkinTracker().updateViewer(info.player);
            }
        }
    }

    // Tracks player location and yaw to determine when the player should be updated
    // with nearby skins.
    private static class PlayerTracker {
        Set<SkinnableEntity> fovVisibleSkins = new HashSet<>(10);
        boolean hasMoved;
        Location location = new Location(null, 0, 0, 0);
        float lowerBound;
        int rotationCount;
        float startYaw;
        float upperBound;

        PlayerTracker(Player player) {
            hardReset(player);
        }

        // reset all
        void hardReset(Player player) {
            hasMoved = false;
            rotationCount = 0;
            lowerBound = upperBound = startYaw = 0;
            fovVisibleSkins.clear();
            reset(player);
        }

        // resets initial yaw and location to the players current location and yaw.
        void reset(Player player) {
            player.getLocation(location);
            if (rotationCount < 3) {
                float rotationDegrees = Setting.NPC_SKIN_ROTATION_UPDATE_DEGREES.asFloat();
                float yaw = Util.clamp(location.getYaw());
                startYaw = yaw;
                upperBound = Util.clamp(yaw + rotationDegrees);
                lowerBound = Util.clamp(yaw - rotationDegrees);
                if (upperBound == -180.0 && startYaw > 0) {
                    upperBound = 0;
                }
            }
        }

        boolean shouldUpdate(Player player) {
            Location currentLoc = player.getLocation();

            // make sure player is in same world
            if (!currentLoc.getWorld().equals(location.getWorld())) {
                hardReset(player);
                return true;
            }
            if (!hasMoved) {
                hasMoved = true;
                return true;
            }
            if (rotationCount < 3) {
                float yaw = Util.clamp(currentLoc.getYaw());
                boolean hasRotated;
                if (startYaw - 90 < -180 || startYaw + 90 > 180) {
                    hasRotated = yaw > lowerBound && yaw < upperBound;
                } else {
                    hasRotated = yaw < lowerBound || yaw > upperBound;
                }
                // update the first 3 times the player rotates. helps load skins around player
                // after the player logs/teleports.
                if (hasRotated) {
                    rotationCount++;
                    reset(player);
                    return true;
                }
            }
            // update every time a player moves a certain distance
            if (currentLoc.distance(location) > MOVEMENT_SKIN_UPDATE_DISTANCE) {
                reset(player);
                return true;
            } else
                return false;
        }
    }

    private static class UpdateInfo {
        SkinnableEntity entity;
        Player player;

        UpdateInfo(Player player, SkinnableEntity entity) {
            this.player = player;
            this.entity = entity;
        }
    }

    private static float FIELD_OF_VIEW = 70F;
    private static int MOVEMENT_SKIN_UPDATE_DISTANCE = 25;
}
