package tc.oc.pgm.fallingblocks;

import gnu.trove.iterator.TLongObjectIterator;
import gnu.trove.map.TLongObjectMap;
import gnu.trove.map.hash.TLongObjectHashMap;
import gnu.trove.set.TLongSet;
import gnu.trove.set.hash.TLongHashSet;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.entity.FallingBlock;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.material.MaterialData;
import org.bukkit.scheduler.BukkitTask;
import tc.oc.commons.bukkit.util.LongDeque;
import tc.oc.pgm.events.ListenerScope;
import tc.oc.pgm.match.Match;
import tc.oc.pgm.match.MatchScope;
import tc.oc.pgm.match.ParticipantState;
import tc.oc.pgm.events.BlockTransformEvent;
import tc.oc.pgm.events.ParticipantBlockTransformEvent;
import tc.oc.pgm.match.MatchModule;
import tc.oc.commons.bukkit.util.Materials;

import javax.annotation.Nullable;
import java.util.List;
import java.util.logging.Level;

import static tc.oc.commons.bukkit.util.BlockUtils.encodePos;
import static tc.oc.commons.bukkit.util.BlockUtils.neighborPos;
import static tc.oc.commons.bukkit.util.BlockUtils.blockAt;

@ListenerScope(MatchScope.RUNNING)
public class FallingBlocksMatchModule extends MatchModule implements Listener {
    private static final BlockFace[] NEIGHBORS = { BlockFace.WEST, BlockFace.EAST, BlockFace.DOWN, BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH };

    // Maximum total blocks to search through over a single tick
    private static final int MAX_SEARCH_VISITS_PER_TICK = 4096;
    private static class MaxSearchVisitsExceeded extends Exception {}
    private int visitsThisTick, visitsWorstTick;

    private final List<FallingBlocksRule> rules;
    private final TLongObjectMap<TLongObjectMap<ParticipantState>> blockDisturbersByTick = new TLongObjectHashMap<>();
    private BukkitTask task;

    public FallingBlocksMatchModule(Match match, List<FallingBlocksRule> rules) {
        super(match);
        this.rules = rules;
    }

    private @Nullable FallingBlocksRule ruleWithShortestDelay(BlockState block) {
        FallingBlocksRule shortest = null;
        for(FallingBlocksRule rule : this.rules) {
            if(rule.canFall(block) && (shortest == null || shortest.delay > rule.delay)) {
                shortest = rule;
            }
        }
        return shortest;
    }

    @Override
    public void enable() {
        super.enable();
        this.task = this.getMatch().getServer().getScheduler().runTaskTimer(this.getMatch().getPlugin(), new Runnable() {
            @Override public void run() {
                FallingBlocksMatchModule.this.fallCheck();
            }
        }, 0, 1);
    }

    @Override
    public void disable() {
        if(this.task != null) {
            this.task.cancel();
            this.task = null;
        }

        logger.info("Longest search for this match: " + this.visitsWorstTick);
        super.disable();
    }

    private void logError(MaxSearchVisitsExceeded ex) {
        getMatch().getMap().getLogger().log(Level.SEVERE, "Exceeded max search visits (" + MAX_SEARCH_VISITS_PER_TICK + ") for this tick", ex);
    }

    /**
     * Test if the given block is either self-supporting (doesn't match any falling rules) or is adjacent to a supported block.
     * The supported and unsupported arguments may contain the results of previous completed searches. The blocks visited by
     * this search will be added to one or the other set depending on the final result.
     *
     * @param pos           position of the block to test
     * @param supported     set of blocks already known to be supported
     * @param unsupported   set of blocks already known to be unsupported
     *
     * @return              true iff the given block is definitely supported
     */
    private boolean isSupported(long pos, TLongSet supported, TLongSet unsupported) throws MaxSearchVisitsExceeded {
        World world = this.getMatch().getWorld();

        LongDeque queue = new LongDeque();
        TLongSet visited = new TLongHashSet();
        queue.add(pos);
        visited.add(pos);

        while(!queue.isEmpty()) {
            pos = queue.remove();

            if(supported.contains(pos)) {
                // If we find a block already known to be supported, it supports all blocks visited in the search.
                supported.addAll(visited);
                return true;
            }

            if(++this.visitsThisTick > MAX_SEARCH_VISITS_PER_TICK) {
                throw new MaxSearchVisitsExceeded();
            }

            if(unsupported.contains(pos)) {
                // Don't continue the search through blocks known to be unsupported
                continue;
            }

            Block block = blockAt(world, pos);
            if(block == null) continue;

            boolean selfSupporting = true;
            for(FallingBlocksRule rule : this.rules) {
                if(rule.canFall(block)) {
                    // If a rule matches, this block is not self-supporting,
                    // and its status depends on the final result of the search.
                    selfSupporting = false;

                    // Continue the search through any neighbors that are capable of
                    // supporting this block, and have not yet been visited.
                    for(BlockFace face : NEIGHBORS) {
                        long neighborPos = neighborPos(pos, face);
                        if(!visited.contains(neighborPos)) {
                            Block neighbor = blockAt(world, neighborPos);
                            if(rule.canSupport(neighbor, face)) {
                                queue.add(neighborPos);
                                visited.add(neighborPos);
                            }
                        }
                    }
                }
            }

            if(selfSupporting) {
                // If no rules match this block, then it is self-supporting,
                // and it can support all the other visited blocks.
                supported.addAll(visited);
                return true;
            }
        }

        // If the entire block network has been searched without finding a
        // supported block, then we know the entire network is unsupported.
        unsupported.addAll(visited);
        return false;
    }

    /**
     * Return the number of unsupported blocks connected to any blocks neighboring the given location,
     * which is assumed to contain an air block. The search may bail out early when the count is greater
     * or equal to the given limit, though this cannot be guaranteed.
     */
    private int countUnsupportedNeighbors(long pos, int limit) {
        TLongSet supported = new TLongHashSet();
        TLongSet unsupported = new TLongHashSet();

        try {
            for(BlockFace face : NEIGHBORS) {
                if(!this.isSupported(neighborPos(pos, face), supported, unsupported)) {
                    if(unsupported.size() >= limit) break;
                }
            }
        }
        catch(MaxSearchVisitsExceeded ex) {
            this.logError(ex);
        }

        return unsupported.size();
    }

    /**
     * Return the number of unsupported blocks connected to any blocks neighboring the given location.
     * An air block is placed there temporarily if it is not already air. The search may bail out early
     * when the count is >= the given limit, though this cannot be guaranteed.
     */
    public int countUnsupportedNeighbors(Block block, int limit) {
        BlockState state = null;
        if(block.getType() != Material.AIR) {
            state = block.getState();
            block.setTypeIdAndData(0, (byte) 0, false);
        }

        int count = countUnsupportedNeighbors(encodePos(block), limit);

        if(state != null) {
            block.setTypeIdAndData(state.getTypeId(), state.getRawData(), false);
        }

        return count;
    }

    /**
     * Make any unsupported blocks fall that are disturbed for the current tick
     */
    private void fallCheck() {
        this.visitsWorstTick = Math.max(this.visitsWorstTick, this.visitsThisTick);
        this.visitsThisTick = 0;

        World world = this.getMatch().getWorld();
        TLongObjectMap<ParticipantState> blockDisturbers = this.blockDisturbersByTick.remove(this.getMatch().getClock().now().tick);
        if(blockDisturbers == null) return;

        TLongSet supported = new TLongHashSet();
        TLongSet unsupported = new TLongHashSet();
        TLongObjectMap<ParticipantState> fallsByBreaker = new TLongObjectHashMap<>();

        try {
            while(!blockDisturbers.isEmpty()) {
                long pos = blockDisturbers.keySet().iterator().next();
                ParticipantState breaker = blockDisturbers.remove(pos);

                // Search down for the first block that can actually fall
                for(;;) {
                    long below = neighborPos(pos, BlockFace.DOWN);
                    if(!Materials.isColliding(blockAt(world, below).getType())) break;
                    blockDisturbers.remove(pos); // Remove all the blocks we find along the way
                    pos = below;
                }

                // Check if the block needs to fall, if it isn't already falling
                if(!fallsByBreaker.containsKey(pos) && !this.isSupported(pos, supported, unsupported)) {
                    fallsByBreaker.put(pos, breaker);
                }
            }
        } catch(MaxSearchVisitsExceeded ex) {
            this.logError(ex);
        }

        for(TLongObjectIterator<ParticipantState> iter = fallsByBreaker.iterator(); iter.hasNext();) {
            iter.advance();
            this.fall(iter.key(), iter.value());
        }
    }

    @SuppressWarnings("deprecation")
    private void fall(long pos, @Nullable ParticipantState breaker) {
        // Block must be removed BEFORE spawning the FallingBlock, or it will not appear on the client
        // https://bugs.mojang.com/browse/MC-72248
        Block block = blockAt(this.getMatch().getWorld(), pos);
        BlockState oldState = block.getState();
        block.setType(Material.AIR, false);
        FallingBlock fallingBlock = oldState.spawnFallingBlock();

        BlockFallEvent event = new BlockFallEvent(block, fallingBlock);
        getMatch().callEvent(breaker == null ? new BlockTransformEvent(event, block, Material.AIR)
                                             : new ParticipantBlockTransformEvent(event, block, Material.AIR, breaker));

        if(event.isCancelled()) {
            fallingBlock.remove();
            oldState.update(true, false); // Restore the old block if the fall is cancelled
        } else {
            // This is already air, but physics have not been applied yet, so do that now
            block.simulateChangeForNeighbors(oldState.getMaterialData(), new MaterialData(Material.AIR));
        }
    }

    private void disturb(long pos, BlockState blockState, @Nullable ParticipantState disturber) {
        FallingBlocksRule rule = this.ruleWithShortestDelay(blockState);
        if(rule != null) {
            long tick = this.getMatch().getClock().now().tick + rule.delay;
            TLongObjectMap<ParticipantState> blockDisturbers = this.blockDisturbersByTick.get(tick);

            if(blockDisturbers == null) {
                blockDisturbers = new TLongObjectHashMap<>();
                this.blockDisturbersByTick.put(tick, blockDisturbers);
            }

            Block block = blockState.getBlock();
            if(!blockDisturbers.containsKey(pos)) {
                blockDisturbers.put(pos, disturber);
            }
        }
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void onBlockChange(BlockTransformEvent event) {
        BlockState newState = event.getNewState();
        Block block = newState.getBlock();
        long pos = encodePos(block);

        // Only breaks are credited. Making a bridge fall by updating a block
        // does not credit you with breaking the bridge.
        ParticipantState breaker = event.isBreak() ? ParticipantBlockTransformEvent.getPlayerState(event) : null;

        if(!(event.getCause() instanceof BlockFallEvent)) {
            this.disturb(pos, newState, breaker);
        }

        for(BlockFace face : NEIGHBORS) {
            this.disturb(neighborPos(pos, face), block.getRelative(face).getState(), breaker);
        }
    }
}
