package tc.oc.pgm.spawns;

import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.logging.Level;
import javax.annotation.Nullable;
import javax.inject.Inject;

import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import org.bukkit.entity.Entity;
import org.bukkit.event.EventException;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.jdom2.Element;
import tc.oc.commons.bukkit.event.CoarsePlayerMoveEvent;
import tc.oc.commons.core.random.RandomUtils;
import tc.oc.commons.core.util.ThrowingConsumer;
import tc.oc.pgm.events.CompetitorRemoveEvent;
import tc.oc.pgm.events.ListenerScope;
import tc.oc.pgm.events.MatchBeginEvent;
import tc.oc.pgm.events.MatchEndEvent;
import tc.oc.pgm.events.MatchPlayerDeathEvent;
import tc.oc.pgm.events.ObserverInteractEvent;
import tc.oc.pgm.events.PlayerChangePartyEvent;
import tc.oc.pgm.filters.Filter;
import tc.oc.pgm.filters.query.IQuery;
import tc.oc.pgm.kits.Kit;
import tc.oc.pgm.match.Competitor;
import tc.oc.pgm.match.Match;
import tc.oc.pgm.match.MatchModule;
import tc.oc.pgm.match.MatchPlayer;
import tc.oc.pgm.match.MatchScope;
import tc.oc.pgm.match.Repeatable;
import tc.oc.pgm.spawns.states.Joining;
import tc.oc.pgm.spawns.states.Observing;
import tc.oc.pgm.spawns.states.State;
import tc.oc.pgm.xml.InvalidXMLException;

import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowConsumer;
import static tc.oc.commons.core.util.MapUtils.ifPresent;

@ListenerScope(MatchScope.LOADED)
public class SpawnMatchModule extends MatchModule implements Listener {

    private final SpawnModule module;
    private final Map<MatchPlayer, State> states = new HashMap<>();
    private final ListMultimap<MatchPlayer, State> transitions = ArrayListMultimap.create();
    private final Set<MatchPlayer> processing = new HashSet<>();

    private final Map<Competitor, Spawn> uniqueSpawns = new HashMap<>();
    private final Set<Spawn> failedSpawns = new HashSet<>();

    @Inject private ObserverToolFactory observerToolFactory;

    public SpawnMatchModule(Match match, SpawnModule module) {
        super(match);
        this.module = module;
    }

    public RespawnOptions getRespawnOptions(IQuery query) {
        return module.respawnOptions.stream().filter(respawnOption -> respawnOption.filter.query(query).equals(Filter.QueryResponse.ALLOW))
                .findFirst().orElseThrow(() -> new IllegalStateException("No respawn option could be used"));
    }

    public Spawn getDefaultSpawn() {
        return module.defaultSpawn;
    }

    public List<Spawn> getSpawns() {
        return module.spawns;
    }

    public List<Kit> getPlayerKits() {
        return module.playerKits;
    }

    public ObserverToolFactory getObserverToolFactory() {
        return observerToolFactory;
    }

    /**
     * Return all {@link Spawn}s that the given player is currently allowed to spawn at
     */
    public List<Spawn> getSpawns(MatchPlayer player) {
        List<Spawn> result = Lists.newArrayList();
        for(Spawn spawn : this.getSpawns()) {
            if(spawn.allows(player)) {
                result.add(spawn);
            }
        }
        return result;
    }

    /**
     * Return a randomly chosen {@link Spawn} that the given player is currently allowed
     * to spawn at, or null if none are available. If a team is given, assume the player
     * will have switched to that team by the time they spawn.
     */
    public @Nullable Spawn chooseSpawn(MatchPlayer player) {
        Competitor competitor = player.getCompetitor();
        if(player.isObserving()) {
            return getDefaultSpawn();
        } else if(competitor != null && uniqueSpawns.containsKey(competitor)) {
            return uniqueSpawns.get(competitor);
        } else {
            List<Spawn> potential = getSpawns(player);
            potential.removeAll(uniqueSpawns.values());
            if(!potential.isEmpty()) {
                Spawn spawn = RandomUtils.element(match.getRandom(), potential);
                if(spawn.attributes().exclusive) uniqueSpawns.put(competitor, spawn);
                return spawn;
            } else {
                return null;
            }
        }
    }

    @Repeatable(scope = MatchScope.LOADED)
    public void tick() {
        // Copy states so they can transition without concurrent modification
        ImmutableMap.copyOf(states).forEach((player, state) -> {
            state.tick();
            processQueuedTransitions(player);
        });
    }

    public void reportFailedSpawn(Spawn spawn, MatchPlayer player) {
        if(failedSpawns.add(spawn)) {
            Element elSpawn = getMatch().getModuleContext().features().definitionNode(spawn);
            InvalidXMLException ex = new InvalidXMLException("Failed to generate spawn location for " + player.getName(), elSpawn);
            getMatch().getMap().getLogger().log(Level.SEVERE, ex.getMessage(), ex);
        }
    }

    private void leaveState(MatchPlayer player) {
        final State state = states.get(player);
        if(state != null) {
            logger.fine(player.getName() + " leave " + state);
            state.leaveState();
            states.remove(player);
        }
    }

    private void enterState(MatchPlayer player, State state) {
        logger.fine(player.getName() + " enter " + state);
        states.put(player, state);
        state.enterState();
    }

    private void changeState(MatchPlayer player, @Nullable State state) {
        leaveState(player);
        if(state != null) {
            enterState(player, state);
        }
    }

    private boolean hasQueuedTransitions(MatchPlayer player) {
        return transitions.containsKey(player);
    }

    private void processQueuedTransitions(MatchPlayer player) {
        // Prevent nested processing of the same player
        if(processing.add(player)) {
            try {
                final List<State> queue = transitions.get(player);
                while(!queue.isEmpty()) {
                    changeState(player, queue.remove(0));
                }
            } finally {
                processing.remove(player);
            }
        }
    }

    public void transition(MatchPlayer player, @Nullable State newState) {
        logger.fine(player.getName() + " queue " + newState);
        transitions.put(player, newState);
    }

    private <X extends Throwable> void withState(@Nullable MatchPlayer player, ThrowingConsumer<State, X> consumer) throws X {
        if(player == null) return;
        ifPresent(states, player, rethrowConsumer(consumer::acceptThrows));
    }

    private void withState(@Nullable Entity bukkit, BiConsumer<MatchPlayer, State> consumer) {
        final MatchPlayer player = match.getPlayer(bukkit);
        withState(player, state -> consumer.accept(player, state));
    }

    private void dispatchEvent(@Nullable MatchPlayer player, Consumer<State> consumer) {
        withState(player, state -> {
            consumer.accept(state);
            processQueuedTransitions(player);
        });
    }

    private void dispatchEvent(@Nullable Entity bukkit, BiConsumer<MatchPlayer, State> consumer) {
        withState(bukkit, (player, state) -> {
            consumer.accept(player, state);
            processQueuedTransitions(player);
        });
    }


    // Events delegated to States

    @EventHandler(priority = EventPriority.MONITOR)
    public void onPartyChange(final PlayerChangePartyEvent event) throws EventException {
        final MatchPlayer player = event.getPlayer();
        if(event.getOldParty() == null) {
            // Join match
            event.yield();
            if(event.getNewParty().isParticipating()) {
                enterState(player, new Joining(player));
            } else {
                enterState(player, new Observing(player, true, true));
            }
        } else if(event.getNewParty() == null) {
            // Leave match
            leaveState(player);
        } else {
            // Party change during match
            withState(player, state -> {
                state.onEvent(event);
                if(hasQueuedTransitions(player)) {
                    // If the party change caused a state transition, leave the old
                    // state before the change, and enter the new state afterward.

                    // The potential danger here is that the player has no spawn state
                    // during the party change, while other events are firing. The
                    // danger is minimized by listening at MONITOR priority.

                    leaveState(player);
                    event.yield();
                    processQueuedTransitions(player);
                }
            });
        }
    }

    /** Must run before {@link tc.oc.pgm.tracker.trackers.DeathTracker#onPlayerDeath} */
    @EventHandler(priority = EventPriority.LOW)
    public void onVanillaDeath(final PlayerDeathEvent event) {
        dispatchEvent(event.getEntity(), (player, state) -> state.onEvent(event));
    }

    @EventHandler(priority = EventPriority.HIGH)
    public void onDeath(final MatchPlayerDeathEvent event) {
        dispatchEvent(event.getVictim(), state -> state.onEvent(event));
    }

    @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
    public void onInventoryClick(final InventoryClickEvent event) {
        dispatchEvent(event.getWhoClicked(), (player, state) -> state.onEvent(event));
    }

    // Listen on HIGH so the picker can handle this first
    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
    public void onObserverInteract(final ObserverInteractEvent event) {
        dispatchEvent(event.getPlayer(), state -> state.onEvent(event));
    }

    @EventHandler
    public void matchBegin(final MatchBeginEvent event) {
        // Copy states so they can transition without concurrent modification
        ImmutableMap.copyOf(states).forEach((player, state) -> {
            state.onEvent(event);
            processQueuedTransitions(player);
        });
    }

    @EventHandler
    public void matchEnd(final MatchEndEvent event) {
        // Copy states so they can transition without concurrent modification
        ImmutableMap.copyOf(states).forEach((player, state) -> {
            // This event can be fired from inside a party change, so some players may have no party
            if(player.hasParty()) {
                state.onEvent(event);
                processQueuedTransitions(player);
            }
        });
    }

    @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
    public void playerMove(final CoarsePlayerMoveEvent event) {
        dispatchEvent(event.getPlayer(), (player, state) -> state.onEvent(event));
    }


    // Events handled for other reasons

    @EventHandler(priority = EventPriority.MONITOR)
    public void onInitialSpawn(final PlayerInitialSpawnEvent event) {
        // Make all joining players spawn in this match's world
        event.setSpawnLocation(match.getWorld().getSpawnLocation());
    }

    @EventHandler(priority = EventPriority.LOW)
    public void playerJoin(final PlayerJoinEvent event) {
        // Add the player to the match if they spawn in this world
        if(match.getWorld().equals(event.getPlayer().getLocation().getWorld())) {
            event.getPlayer().setGliding(true); // Fixes client desync if player joins server while gliding
            match.addPlayer(event.getPlayer());
        }
    }

    @EventHandler(priority = EventPriority.MONITOR)
    public void playerQuit(final PlayerQuitEvent event) {
        match.removePlayer(event.getPlayer());
    }

    @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
    public void onCompetitorRemove(CompetitorRemoveEvent event) {
        // If a competitor is no longer valid, free up its provider
        Competitor competitor = event.getCompetitor();
        if(uniqueSpawns.containsKey(competitor)) {
            Spawn spawn = uniqueSpawns.get(competitor);
            // Do not change if persistence is enabled
            if(!spawn.attributes().persistent) {
                uniqueSpawns.remove(competitor);
            }
        }
    }
}
