package tc.oc.commons.bukkit.listeners;

import java.util.Map;
import java.util.WeakHashMap;
import javax.inject.Inject;
import javax.inject.Singleton;

import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
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.PlayerAnimationEvent;
import org.bukkit.event.player.PlayerAnimationType;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause;
import org.bukkit.util.Vector;
import tc.oc.commons.bukkit.chat.BukkitAudiences;
import tc.oc.commons.bukkit.event.BlockPunchEvent;
import tc.oc.commons.bukkit.event.BlockTrampleEvent;
import tc.oc.commons.bukkit.event.CoarsePlayerMoveEvent;
import tc.oc.commons.bukkit.util.BlockUtils;
import tc.oc.commons.bukkit.util.Materials;
import tc.oc.commons.core.plugin.PluginFacet;

/**
 * Translates standard Bukkit events into a few extra events:
 *   {@link CoarsePlayerMoveEvent}
 *   {@link BlockPunchEvent}
 *   {@link BlockTrampleEvent}
 */
@Singleton
public class PlayerMovementListener implements PluginFacet, Listener {

    protected final EventBus eventBus;

    // The last location of a player that has been used to generate
    // coarse movement events. If a player is not in this list, then
    // the next movement event they generate can be assumed valid
    // on its own.
    private final Map<Player, EntityLocation> lastToLocation = new WeakHashMap<>();

    @Inject PlayerMovementListener(EventBus eventBus) {
        this.eventBus = eventBus;
    }

    /**
     * Update the last known location of a player to account for the
     * given movement event
     */
    private void updateLastToLocation(final PlayerMoveEvent event) {
        if(event.isCancelled()) {
            this.lastToLocation.put(event.getPlayer(), event.getEntityFrom());
        } else {
            this.lastToLocation.put(event.getPlayer(), event.getEntityTo());
        }
    }

    @EventHandler(priority = EventPriority.MONITOR)
    public void onPlayerQuit(PlayerQuitEvent event) {
        lastToLocation.remove(event.getPlayer());
    }

    // -------------------------
    // ---- Player movement ----
    // -------------------------

    @EventHandler(priority = EventPriority.HIGHEST)
    public void onPlayerMoveHigh(final PlayerMoveEvent event) {
        this.handleMovementHigh(event);
    }

    @EventHandler(priority = EventPriority.HIGHEST)
    public void onPlayerTeleportHigh(final PlayerTeleportEvent event) {
        this.handleMovementHigh(event);
    }

    private final void handleMovementHigh(final PlayerMoveEvent event) {
        Player player = event.getPlayer();
        EntityLocation originalFrom = event.getEntityFrom();
        EntityLocation originalTo = event.getEntityTo();

        EntityLocation oldTo = this.lastToLocation.get(player);
        if(oldTo != null && !oldTo.equals(originalFrom)) {
            // If this movement does not start where the last known movement ended,
            // we have to make up the missing movement. We do that by (potentially) firing
            // two coarse events for this one event, a "fake" one for the missing movement
            // and a "real" one for the current movement.

            // First, modify this event to look like the missing event, and fire
            // a coarse event that wraps it.
            event.setFrom(oldTo);
            event.setTo(originalFrom);
            this.updateLastToLocation(event);
            if(this.callCoarsePlayerMove(event)) {
                // If the fake coarse event was cancelled, we don't need to fire
                // the real one, so just return. Note that the wrapped event won't
                // actually be cancelled, rather its to location will be modified
                // to return the player to the oldTo location. Also note that if
                // the original event was already cancelled before the coarse event
                // fired, then we will never get here, and both the fake and real
                // events will go through.
                this.updateLastToLocation(event);
                return;
            }

            // Restore the event to its real state
            event.setFrom(originalFrom);
            event.setTo(originalTo);
        }

        this.updateLastToLocation(event);
        if(this.callCoarsePlayerMove(event)) {
            this.updateLastToLocation(event);
        }
    }

    /**
     * Fire a CoarsePlayerMoveEvent that wraps the given event, only if it crosses
     * a block boundary, or the PoseFlags change.
     * @param event         The movement event to potentially wrap
     * @return True if the original event was not cancelled, and a coarse event was fired,
     *         and that coarse event was cancelled. In this case, the wrapped event won't
     *         actually be cancelled, but callers should treat it like it is.
     */
    private boolean callCoarsePlayerMove(final PlayerMoveEvent event) {
        // Don't fire coarse events for teleports that are not "in-game"
        // e.g. /jumpto command
        if(event instanceof PlayerTeleportEvent) {
            PlayerTeleportEvent teleportEvent = (PlayerTeleportEvent) event;
            if(teleportEvent.getCause() != TeleportCause.ENDER_PEARL &&
               teleportEvent.getCause() != TeleportCause.UNKNOWN) {
                return false;
            }
        }

        // If the movement does not cross a block boundary, and no PoseFlags changed, we don't care about it
        final EntityLocation from = event.getEntityFrom();
        final EntityLocation to = event.getEntityTo();
        if(from.position().coarseEquals(to.position()) && from.poseFlags().equals(to.poseFlags())) {
            return false;
        }

        // Remember whether the original event was already cancelled
        boolean wasCancelled = event.isCancelled();

        CoarsePlayerMoveEvent generalEvent = new CoarsePlayerMoveEvent(event, event.getPlayer(), from, to);
        this.eventBus.callEvent(generalEvent);

        if(!wasCancelled && generalEvent.isCancelled()) {
            // When a coarse event is cancelled, we have our own logic for resetting the
            // player's position, so we un-cancel the event and instead modify its
            // to location to put the player where we want them.
            resetPosition(event);
            return true;
        } else {
            return false;
        }
    }

    /**
     * Modify the to location of the given event to prevent the movement and
     * move the player so they are standing on the center of the block at the
     * from location.
     */
    private static void resetPosition(final PlayerMoveEvent event) {
        Location newLoc;
        double yValue = event.getFrom().getY();

        if(yValue <= 0 || event instanceof PlayerTeleportEvent) {
            newLoc = event.getFrom();
        } else {
            newLoc = BlockUtils.center(event.getFrom()).subtract(new Vector(0, 0.5, 0));
            if(newLoc.getBlock() != null) {
                switch(newLoc.getBlock().getType()) {
                case STEP:
                case WOOD_STEP:
                    newLoc.add(new Vector(0, 0.5, 0));
                    break;
                default: break;
                }
            }
        }

        newLoc.setPitch(event.getTo().getPitch());
        newLoc.setYaw(event.getTo().getYaw());
        event.setCancelled(false);
        event.setTo(newLoc);
    }

    // reset the last location on death
    @EventHandler(priority = EventPriority.MONITOR)
    public void onPlayerRespawn(final PlayerRespawnEvent event) {
        this.lastToLocation.remove(event.getPlayer());
    }

    @EventHandler(priority = EventPriority.MONITOR)
    public void onPlayerMoveMonitor(final PlayerMoveEvent event) {
        this.handleMovementMonitor(event);
    }

    @EventHandler(priority = EventPriority.MONITOR)
    public void onPlayerTeleportMonitor(final PlayerTeleportEvent event) {
        this.handleMovementMonitor(event);
    }

    private void handleMovementMonitor(PlayerMoveEvent event) {
        // It's possible for a PlayerMoveEvent to be modified by another
        // HIGHEST handler after we handle it, so we also check it at MONITOR
        this.updateLastToLocation(event);
    }

    // ------------------------------------------
    // ---- Adventure mode block interaction ----
    // ------------------------------------------

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void detectBlockPunch(PlayerAnimationEvent event) {
        if(event.getAnimationType() != PlayerAnimationType.ARM_SWING) return;
        if(event.getPlayer().getGameMode() != GameMode.ADVENTURE) return;

        // Client will not punch blocks in adventure mode, so we detect it ourselves and fire a BlockPunchEvent.
        // We do this in the kit module only because its the one that is responsible for putting players in adventure mode.
        // A few other modules rely on this, including StaminaModule and BlockDropsModule.
        RayBlockIntersection hit = event.getPlayer().getTargetedBlock(true, false);
        if(hit == null) return;

        eventBus.callEvent(new BlockPunchEvent(event.getPlayer(), hit));
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void detectBlockTrample(CoarsePlayerMoveEvent event) {
        if(!event.getPlayer().isOnGround()) return;

        Block block = event.getBlockTo().getBlock();
        if(!Materials.isColliding(block.getType())) {
            block = block.getRelative(BlockFace.DOWN);
            if(!Materials.isColliding(block.getType())) return;
        }

        eventBus.callEvent(new BlockTrampleEvent(event.getPlayer(), block));
    }

    // -------------------------
    // ---- Cancel messages ----
    // -------------------------

    @EventHandler(priority = EventPriority.MONITOR)
    public void processCancelMessage(final CoarsePlayerMoveEvent event) {
        if(event.isCancelled() && event.getCancelMessage() != null) {
            BukkitAudiences.getAudience(event.getPlayer()).sendWarning(event.getCancelMessage(), false);
        }
    }
}
