package tc.oc.pgm.renewable;

import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.logging.Logger;

import com.google.common.collect.ImmutableRangeMap;
import com.google.common.collect.Range;
import com.google.common.collect.RangeMap;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.geometry.Vec3;
import org.bukkit.material.MaterialData;
import org.bukkit.util.BlockVector;
import tc.oc.commons.bukkit.util.BlockFaces;
import tc.oc.commons.bukkit.util.BlockUtils;
import tc.oc.commons.bukkit.util.BlockVectorSet;
import tc.oc.commons.bukkit.util.MaterialCounter;
import tc.oc.commons.bukkit.util.NMSHacks;
import tc.oc.commons.core.logging.ClassLogger;
import tc.oc.pgm.events.ListenerScope;
import tc.oc.pgm.match.Match;
import tc.oc.pgm.match.MatchPlayer;
import tc.oc.pgm.events.BlockTransformEvent;
import tc.oc.pgm.filters.Filter;
import tc.oc.pgm.filters.query.BlockQuery;
import tc.oc.pgm.match.MatchScope;
import tc.oc.pgm.match.Repeatable;
import tc.oc.pgm.snapshot.SnapshotMatchModule;

@ListenerScope(MatchScope.RUNNING)
public class Renewable implements Listener {

    private static final int MAX_FAILED_ITERATIONS = 100;
    private static final int SHUFFLE_SAMPLE_ITERATIONS = 10;
    private static final int SHUFFLE_SAMPLE_RANGE = 5;

    private final RenewableDefinition definition;
    private final Match match;
    private final Logger logger;
    // Current inverse distribution of shuffleable materials relative to the initial state (which is unknown).
    // This is used to choose a material when renewing shuffleable blocks.
    private final MaterialCounter shuffleableMaterialDeficit = new MaterialCounter();

    // Set of blocks that are immediately renewable, dynamically updated from block events.
    // Maintaining this set avoids nearly all trial and error logic in the renewal tick.
    private final BlockVectorSet renewablePool = new BlockVectorSet();

    // Number of blocks that currently must to be renewed to keep up with the configured rate.
    private long lastTick;

    private SnapshotMatchModule snapshotMatchModule;

    // Cached queries of the renewable/shuffleable filters, invalidated every tick.
    // These are queries of the original blocks, not the current blocks.
    // This should cut down on repeated queries.
    private Map<BlockVector, Filter.QueryResponse> renewableCache = new HashMap<>();
    private Map<BlockVector, Filter.QueryResponse> shuffleableCache = new HashMap<>();

    public Renewable(RenewableDefinition definition, Match match, Logger parent) {
        this.definition = definition;
        this.match = match;
        this.logger = new ClassLogger(parent, getClass());

        updateLastTick();
    }

    void invalidateCaches() {
        renewableCache.clear();
        shuffleableCache.clear();
    }

    SnapshotMatchModule snapshot() {
        if(snapshotMatchModule == null) {
            snapshotMatchModule = match.needMatchModule(SnapshotMatchModule.class);
        }
        return snapshotMatchModule;
    }

    boolean isOriginalRenewable(BlockVector pos) {
        if(!definition.region.contains(pos)) return false;
        Filter.QueryResponse response = renewableCache.get(pos);
        if(response == null) {
            response = definition.renewableBlocks.query(new BlockQuery(snapshot().getOriginalBlock(pos)));
        }
        return response.isAllowed();
    }

    boolean isOriginalShuffleable(BlockVector pos) {
        if(!definition.region.contains(pos)) return false;
        Filter.QueryResponse response = shuffleableCache.get(pos);
        if(response == null) {
            response = definition.shuffleableBlocks.query(new BlockQuery(snapshot().getOriginalBlock(pos)));
        }
        return response.isAllowed();
    }

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void onBlockChange(BlockTransformEvent event) {
        BlockState oldState = event.getOldState(), newState = event.getNewState();

        updateRenewablePool(newState);

        if(definition.growAdjacent) {
            for(BlockFace face : BlockFaces.NEIGHBORS) {
                updateRenewablePool(BlockFaces.getRelative(newState, face));
            }
        }

        if(isOriginalShuffleable(BlockUtils.position(newState))) {
            if(definition.shuffleableBlocks.query(new BlockQuery(oldState)).isAllowed()) {
                shuffleableMaterialDeficit.increment(oldState, 1);
            }

            if(definition.shuffleableBlocks.query(new BlockQuery(newState)).isAllowed()) {
                shuffleableMaterialDeficit.increment(newState, -1);
            }
        }
    }

    @Repeatable
    public void tick(Match match) {
        invalidateCaches();

        float interval = updateLastTick(); // should always be 1
        float count = interval * definition.renewalsPerSecond / 20f; // calculate renewals per tick
        if(definition.rateScaled) count *= renewablePool.size();

        for(;count > 0 && !renewablePool.isEmpty(); count--) {
            if(match.getRandom().nextFloat() < count) {
                for(int i = 0; i < MAX_FAILED_ITERATIONS; i++) {
                    if(renew(renewablePool.chooseRandom(match.getRandom()))) break;
                }
            }
        }
    }

    long updateLastTick() {
        long delta = match.getClock().now().tick - lastTick;
        lastTick = match.getClock().now().tick;
        return delta;
    }

    void updateRenewablePool(BlockState block) {
        if(canRenew(block)) {
            renewablePool.add(BlockUtils.position(block));
        } else {
            renewablePool.remove(BlockUtils.position(block));
        }
    }

    boolean isNew(BlockState currentState) {
        // If original block does not match renewable rule, block is new
        BlockVector pos = BlockUtils.position(currentState);
        if(!isOriginalRenewable(pos)) return true;

        // If original and current material are both shuffleable, block is new
        MaterialData currentMaterial = currentState.getMaterialData();
        if(isOriginalShuffleable(pos) && definition.shuffleableBlocks.query(new BlockQuery(currentState)).isAllowed()) return true;

        // If current material matches original, block is new
        if(currentMaterial.equals(snapshot().getOriginalMaterial(pos))) return true;

        // Otherwise, block is not new (can be renewed)
        return false;
    }

    boolean hasNewNeighbor(BlockState block) {
        for(BlockFace face : BlockFaces.NEIGHBORS) {
            if(isNew(BlockFaces.getRelative(block, face))) return true;
        }
        return false;
    }

    boolean canRenew(BlockState currentState) {
        // Must not already be new
        if(isNew(currentState)) return false;

        // Must grow from an adjacent block that is renewed
        if(definition.growAdjacent && !hasNewNeighbor(currentState)) return false;

        // Current block must be replaceable
        if(!definition.replaceableBlocks.query(new BlockQuery(currentState)).isAllowed()) return false;

        return true;
    }

    boolean isClearOfEntities(Vec3 pos) {
        if(definition.avoidPlayersRange > 0d) {
            double rangeSquared = definition.avoidPlayersRange * definition.avoidPlayersRange;
            pos = pos.blockCenter();
            for(MatchPlayer player : match.getParticipatingPlayers()) {
                Location location = player.getBukkit().getLocation().add(0,1,0);
                if(location.toVector().distanceSquared(pos) < rangeSquared) {
                    return false;
                }
            }
        }
        return true;
    }

    MaterialData sampleShuffledMaterial(BlockVector pos) {
        Random random = match.getRandom();
        int range = SHUFFLE_SAMPLE_RANGE;
        int diameter = range * 2 + 1;
        for(int i = 0; i < SHUFFLE_SAMPLE_ITERATIONS; i++) {
            BlockState block = snapshot().getOriginalBlock(pos.getBlockX() + random.nextInt(diameter) - range,
                                                           pos.getBlockY() + random.nextInt(diameter) - range,
                                                           pos.getBlockZ() + random.nextInt(diameter) - range);
            if(definition.shuffleableBlocks.query(new BlockQuery(block)).isAllowed()) return block.getMaterialData();
        }
        return null;
    }

    MaterialData chooseShuffledMaterial() {
        ImmutableRangeMap.Builder<Double, MaterialData> weightsBuilder = ImmutableRangeMap.builder();
        double sum = 0d;
        for(MaterialData material : shuffleableMaterialDeficit.materials()) {
            double weight = shuffleableMaterialDeficit.get(material);
            if(weight > 0) {
                weightsBuilder.put(Range.closedOpen(sum, sum + weight), material);
                sum += weight;
            }
        }
        RangeMap<Double, MaterialData> weights = weightsBuilder.build();
        return weights.get(match.getRandom().nextDouble() * sum);
    }

    boolean renew(BlockVector pos) {
        MaterialData material;
        if(isOriginalShuffleable(pos)) {
            // If position is shuffled, first try to find a nearby shuffleable block to swap with.
            // This helps to make shuffling less predictable when the material deficit is small or
            // out of proportion to the original distribution of materials.
            material = sampleShuffledMaterial(pos);

            // If that fails, choose a random material, weighted by the current material deficits.
            if(material == null) material = chooseShuffledMaterial();
        } else {
            material = snapshot().getOriginalMaterial(pos);
        }

        if(material != null) {
            return renew(pos, material);
        }

        return false;
    }

    boolean renew(BlockVector pos, MaterialData material) {
        // We need to do the entity check here rather than canRenew, because we are not
        // notified when entities move in our out of the way.
        if(!isClearOfEntities(pos)) return false;

        Location location = pos.toLocation(match.getWorld());
        Block block = location.getBlock();
        BlockState newState = location.getBlock().getState();
        newState.setMaterialData(material);

        BlockRenewEvent event = new BlockRenewEvent(block, newState, this);
        match.callEvent(event); // Our own handler will get this and remove the block from the pool
        if(event.isCancelled()) return false;

        newState.update(true, true);

        if(definition.particles) {
            NMSHacks.playBlockBreakEffect(match.getWorld(), pos, material.getItemType());
        }

        if(definition.sound) {
            NMSHacks.playBlockPlaceSound(match.getWorld(), pos, material.getItemType(), 1f);
        }

        return true;
    }
}