package tc.oc.pgm.tracker.trackers;

import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;

import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import tc.oc.commons.bukkit.util.Materials;
import tc.oc.commons.core.logging.Loggers;
import tc.oc.pgm.events.ListenerScope;
import tc.oc.pgm.match.Match;
import tc.oc.pgm.match.MatchPlayer;
import tc.oc.pgm.match.MatchScope;
import tc.oc.pgm.spawns.events.ParticipantDespawnEvent;
import tc.oc.pgm.time.TickTime;
import tc.oc.pgm.tracker.EventResolver;
import tc.oc.pgm.tracker.damage.DamageInfo;
import tc.oc.pgm.tracker.damage.FallInfo;
import tc.oc.pgm.tracker.damage.FallState;
import tc.oc.pgm.tracker.damage.GenericFallInfo;
import tc.oc.pgm.tracker.damage.PhysicalInfo;
import tc.oc.pgm.tracker.event.PlayerSpleefEvent;
import tc.oc.pgm.tracker.resolvers.DamageResolver;

/**
 * Tracks the state of falls caused by other players and resolves the damage caused by them.
 */
@ListenerScope(MatchScope.RUNNING)
public class FallTracker implements Listener, DamageResolver {
    private final Map<MatchPlayer, FallState> falls = new HashMap<>();

    private final EventResolver eventResolver;
    private final Match match;
    private final Logger logger;

    @Inject FallTracker(Loggers loggers, Match match, EventResolver eventResolver) {
        this.logger = loggers.get(getClass());
        this.eventResolver = eventResolver;
        this.match = match;
    }

    @Override
    public @Nullable FallInfo resolveDamage(EntityDamageEvent.DamageCause damageType, Entity victim, @Nullable PhysicalInfo damager) {
        FallState fall = getFall(victim);

        if(fall != null) {
            switch(damageType) {
                case VOID: fall.to = FallInfo.To.VOID; break;
                case FALL: fall.to = FallInfo.To.GROUND; break;
                case LAVA: fall.to = FallInfo.To.LAVA; break;

                case FIRE_TICK:
                    if(fall.isInLava) {
                        fall.to = FallInfo.To.LAVA;
                    } else {
                        return null;
                    }
                    break;

                default: return null;
            }

            return fall;
        } else {
            switch(damageType) {
                case FALL:
                    return new GenericFallInfo(FallInfo.To.GROUND, victim.getLocation(), victim.getFallDistance());
                case VOID:
                    return new GenericFallInfo(FallInfo.To.VOID, victim.getLocation(), victim.getFallDistance());
            }

            return null;
        }
    }

    @Nullable FallState getFall(Entity victim) {
        MatchPlayer player = match.getPlayer(victim);
        if(player == null) return null;

        FallState fall = falls.get(player);
        if(fall == null || !fall.isStarted || fall.isEnded) return null;

        return fall;
    }

    void endFall(FallState fall) {
        endFall(fall.victim);
    }

    void endFall(MatchPlayer victim) {
        FallState fall = this.falls.remove(victim);
        if(fall != null) {
            fall.isEnded = true;
            logger.fine("Ended " + fall);
        }
    }

    void checkFallTimeout(final FallState fall) {
        TickTime now = match.getClock().now();
        if((fall.isStarted && fall.isEndedSafely(now)) ||
           (!fall.isStarted && fall.isExpired(now))) {

            endFall(fall);
        }
    }

    void scheduleCheckFallTimeout(final FallState fall, final long delay) {
        match.getScheduler(MatchScope.RUNNING).createDelayedTask(delay + 1, () -> {
            if(!fall.isEnded) {
                checkFallTimeout(fall);
            }
        });
    }

    /**
     * Called whenever the player becomes "unsupported" to check if they were attacked recently enough for the attack
     * to be responsible for the fall
     */
    private void playerBecameUnsupported(FallState fall) {
        if(!fall.isStarted && !fall.isSupported() && match.getClock().now().tick - fall.startTime.tick <= FallState.MAX_KNOCKBACK_TICKS) {
            fall.isStarted = true;
            logger.fine("Started " + fall);
        }
    }

    /**
     * Called when a player is damaged in a way that could initiate a Fall,
     * i.e. damage from another entity that causes knockback
     */
    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void onAttack(final EntityDamageEvent event) {
        // Filter out damage types that don't cause knockback
        switch(event.getCause()) {
            case ENTITY_ATTACK:
            case PROJECTILE:
            case BLOCK_EXPLOSION:
            case ENTITY_EXPLOSION:
            case MAGIC:
            case CUSTOM:
                break;

            default:
                return;
        }

        MatchPlayer victim = match.getParticipant(event.getEntity());
        if(victim == null) return;

        if(this.falls.containsKey(victim)) {
            // A new fall can't be initiated if the victim is already falling
            return;
        }

        Location loc = victim.getBukkit().getLocation();
        boolean isInLava = Materials.isLava(loc);
        boolean isClimbing = Materials.isClimbable(loc);
        boolean isSwimming = Materials.isWater(loc);

        DamageInfo cause = eventResolver.resolveDamage(event);

        // Note the victim's situation when the attack happened
        FallInfo.From from;
        if(isClimbing) {
            from = FallInfo.From.LADDER;
        } else if(isSwimming) {
            from = FallInfo.From.WATER;
        } else {
            from = FallInfo.From.GROUND;
        }

        FallState fall = new FallState(victim, from, cause);
        this.falls.put(victim, fall);

        fall.isClimbing = isClimbing;
        fall.isSwimming = isSwimming;
        fall.isInLava = isInLava;

        // If the victim is already in the air, immediately confirm that they are falling.
        // Otherwise, the fall will be confirmed when they leave the ground, if it happens
        // within the time window.
        fall.isStarted = !fall.isSupported();

        if(!fall.isStarted) {
            this.scheduleCheckFallTimeout(fall, FallState.MAX_KNOCKBACK_TICKS);
        }

        logger.fine("Attacked " + fall);
    }

    /**
     * Called when a player moves in a way that could affect their fall i.e. landing on a ladder or in liquid
     */
    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void onPlayerMove(final PlayerMoveEvent event) {
        MatchPlayer player = match.getParticipant(event.getPlayer());
        if(player == null) return;

        FallState fall = this.falls.get(player);
        if(fall != null) {
            boolean isClimbing = Materials.isClimbable(event.getTo());
            boolean isSwimming = Materials.isWater(event.getTo());
            boolean isInLava = Materials.isLava(event.getTo());
            boolean becameUnsupported = false;
            TickTime now = match.getClock().now();

            if(isClimbing != fall.isClimbing) {
                if((fall.isClimbing = isClimbing)) {
                    // Player moved onto a ladder, cancel the fall if they are still on it after MAX_CLIMBING_TIME
                    fall.climbingTick = now.tick;
                    this.scheduleCheckFallTimeout(fall, FallState.MAX_CLIMBING_TICKS + 1);
                } else {
                    becameUnsupported = true;
                }
            }

            if(isSwimming != fall.isSwimming) {
                if((fall.isSwimming = isSwimming)) {
                    // Player moved into water, cancel the fall if they are still in it after MAX_SWIMMING_TIME
                    fall.swimmingTick = now.tick;
                    this.scheduleCheckFallTimeout(fall, FallState.MAX_SWIMMING_TICKS + 1);
                } else {
                    becameUnsupported = true;
                }
            }

            if(becameUnsupported) {
                // Player moved out of water or off a ladder, check if it was caused by the attack
                this.playerBecameUnsupported(fall);
            }

            if(isInLava != fall.isInLava) {
                if((fall.isInLava = isInLava)) {
                    fall.inLavaTick = now.tick;
                } else {
                    // Because players continue to "fall" as long as they are in lava, moving out of lava
                    // can immediately end their fall
                    this.checkFallTimeout(fall);
                }
            }
        }
    }

    /**
     * Called when the player touches or leaves the ground
     */
    @EventHandler(priority = EventPriority.MONITOR)
    public void onPlayerOnGroundChanged(final PlayerOnGroundEvent event) {
        MatchPlayer player = match.getParticipant(event.getPlayer());
        if(player == null) return;

        FallState fall = this.falls.get(player);
        if(fall != null) {
            if(event.getOnGround()) {
                // Falling player landed on the ground, cancel the fall if they are still there after MAX_ON_GROUND_TIME
                fall.onGroundTick = match.getClock().now().tick;
                fall.groundTouchCount++;
                this.scheduleCheckFallTimeout(fall, FallState.MAX_ON_GROUND_TICKS + 1);
            } else {
                // Falling player left the ground, check if it was caused by the attack
                this.playerBecameUnsupported(fall);
            }
        }
    }

    @EventHandler(priority = EventPriority.MONITOR)
    public void onPlayerSpleef(final PlayerSpleefEvent event) {
        MatchPlayer victim = event.getVictim();
        FallState fall = this.falls.get(victim);
        if(fall == null || !fall.isStarted) {
            if(fall != null) {
                // End the existing fall and replace it with the spleef
                endFall(fall);
            }

            fall = new FallState(victim, FallInfo.From.GROUND, event.getSpleefInfo());
            fall.isStarted = true;

            Location loc = victim.getBukkit().getLocation();
            fall.isClimbing = Materials.isClimbable(loc);
            fall.isSwimming = Materials.isWater(loc);
            fall.isInLava = Materials.isLava(loc);

            this.falls.put(victim, fall);

            logger.fine("Spleefed " + fall);
        }
    }

    // NOTE: This must be called after anything that tries to resolve the death
    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void onPlayerDeath(final PlayerDeathEvent event) {
        MatchPlayer player = match.getParticipant(event.getEntity());
        if(player != null) endFall(player);
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void onPlayerDespawn(final ParticipantDespawnEvent event) {
        endFall(event.getPlayer());
    }
}
