package tc.oc.pgm.tracker.trackers;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import javax.annotation.Nullable;
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 org.bukkit.event.player.PlayerOnGroundEvent;
import tc.oc.pgm.api.event.PlayerSpleefEvent;
import tc.oc.pgm.api.match.Match;
import tc.oc.pgm.api.match.MatchScope;
import tc.oc.pgm.api.player.MatchPlayer;
import tc.oc.pgm.api.time.Tick;
import tc.oc.pgm.api.tracker.DamageResolver;
import tc.oc.pgm.api.tracker.info.DamageInfo;
import tc.oc.pgm.api.tracker.info.FallInfo;
import tc.oc.pgm.api.tracker.info.PhysicalInfo;
import tc.oc.pgm.spawns.events.ParticipantDespawnEvent;
import tc.oc.pgm.tracker.TrackerMatchModule;
import tc.oc.pgm.tracker.info.FallState;
import tc.oc.pgm.tracker.info.GenericFallInfo;
import tc.oc.pgm.util.ClassLogger;
import tc.oc.pgm.util.TimeUtils;
import tc.oc.pgm.util.material.Materials;

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

  private final TrackerMatchModule tracker;
  private final Match match;
  private final Logger logger;

  public FallTracker(TrackerMatchModule tracker, Match match) {
    this.tracker = tracker;
    this.match = match;
    this.logger = ClassLogger.get(match.getLogger(), getClass());
  }

  @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) {
    Tick now = match.getTick();
    if ((fall.isStarted && fall.isEndedSafely(now)) || (!fall.isStarted && fall.isExpired(now))) {

      endFall(fall);
    }
  }

  void scheduleCheckFallTimeout(final FallState fall, final long delay) {
    match
        .getExecutor(MatchScope.RUNNING)
        .schedule(
            () -> {
              if (!fall.isEnded) {
                checkFallTimeout(fall);
              }
            },
            (delay + 1) * TimeUtils.TICK,
            TimeUnit.MILLISECONDS);
  }

  /**
   * 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.getTick().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 = tracker.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;
      Tick now = match.getTick();

      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 finish 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.getTick().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());
  }
}