package tc.oc.pgm.destroyable;

import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.api.chat.TranslatableComponent;
import org.bukkit.ChatColor;
import org.bukkit.FireworkEffect;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.entity.Firework;
import org.bukkit.material.MaterialData;
import org.bukkit.util.BlockVector;
import tc.oc.api.docs.virtual.MatchDoc;
import tc.oc.commons.bukkit.chat.NameStyle;
import tc.oc.commons.bukkit.util.BlockUtils;
import tc.oc.commons.bukkit.util.NMSHacks;
import tc.oc.commons.core.chat.Components;
import tc.oc.commons.core.util.DefaultMapAdapter;
import tc.oc.pgm.Config;
import tc.oc.pgm.PGM;
import tc.oc.pgm.blockdrops.BlockDrops;
import tc.oc.pgm.blockdrops.BlockDropsMatchModule;
import tc.oc.pgm.blockdrops.BlockDropsRuleSet;
import tc.oc.pgm.events.FeatureChangeEvent;
import tc.oc.pgm.fireworks.FireworkUtil;
import tc.oc.pgm.goals.IncrementalGoal;
import tc.oc.pgm.goals.ModeChangeGoal;
import tc.oc.pgm.goals.TouchableGoal;
import tc.oc.pgm.goals.events.GoalCompleteEvent;
import tc.oc.pgm.goals.events.GoalStatusChangeEvent;
import tc.oc.pgm.match.Competitor;
import tc.oc.pgm.match.Match;
import tc.oc.pgm.match.MatchPlayer;
import tc.oc.pgm.match.MatchPlayerState;
import tc.oc.pgm.match.ParticipantState;
import tc.oc.pgm.match.Parties;
import tc.oc.pgm.match.Party;
import tc.oc.pgm.regions.FiniteBlockRegion;
import tc.oc.pgm.teams.Team;
import tc.oc.pgm.utils.MaterialPattern;
import tc.oc.pgm.utils.Strings;

public class Destroyable extends TouchableGoal<DestroyableFactory> implements IncrementalGoal<DestroyableFactory>,
                                                                              ModeChangeGoal<DestroyableFactory> {

    // Block replacement rules in this ruleset will be used to calculate destroyable health
    protected final BlockDropsRuleSet blockDropsRuleSet;

    protected final FiniteBlockRegion blockRegion;
    protected final Set<MaterialPattern> materialPatterns = new HashSet<>();
    protected final Set<MaterialData> materials = new HashSet<>();

    // The percentage of blocks that must be broken for the entire Destroyable to be destroyed.
    protected double destructionRequired;

    protected final Duration SPARK_COOLDOWN = Duration.ofMillis(75);
    protected Instant lastSparkTime;

    /**
     * The maximum possible health that this Destroyable can have, which is the sum of the max health
     * of each block. The max health of a block is the maximum number of breaks between any destroyable
     * material and any non-destroyable material. Note that blocks are not necessarily at max health when
     * the match starts. This value can change as the result of mode changes.
     */
    protected int maxHealth;

    // The current health of the Destroyable
    protected int health;

    /**
     * Map of block -> material -> health i.e. the health level that each material represents
     * for each block in the destroyable. For example, (1,2,3) -> Gold Block -> 3 means that
     * when there is a gold block at (1,2,3), it will need to be broken three times to change
     * into a block that is not a destroyable material.
     *
     * This map will have en entry for every destroyable material for every block. If there are
     * no custom block replacement rules affecting this destroyable, this will be null;
     */
    protected Map<BlockVector, Map<MaterialData, Integer>> blockMaterialHealth;

    protected final List<DestroyableHealthChange> events = Lists.newArrayList();
    protected ImmutableList<DestroyableContribution> contributions;

    protected Iterable<Location> proximityLocations;

    public Destroyable(DestroyableFactory definition, Match match) {
        super(definition, match);

        for(MaterialPattern pattern : definition.getMaterials()) {
            addMaterials(pattern);
        }

        this.destructionRequired = definition.getDestructionRequired();

        final FiniteBlockRegion.Factory regionFactory = new FiniteBlockRegion.Factory(match.getMapInfo().proto);
        this.blockRegion = regionFactory.fromWorld(definition.getRegion(), match.getWorld(), this.materialPatterns);
        if(this.blockRegion.blockVolume() == 0) {
            match.getServer().getLogger().warning("No destroyable blocks found in destroyable " + this.getName());
        }

        this.blockDropsRuleSet = match.needMatchModule(BlockDropsMatchModule.class)
                                      .getRuleSet()
                                      .subsetAffecting(this.materials)
                                      .subsetAffecting(this.blockRegion);

        this.recalculateHealth();
    }

    // Remove @Nullable
    @Override
    public @Nonnull Team getOwner() {
        return super.getOwner();
    }

    @Override
    public boolean getDeferTouches() {
        return true;
    }

    @Override
    public BaseComponent getTouchMessage(@Nullable ParticipantState toucher, boolean self) {
        if(toucher == null) {
            return new TranslatableComponent("match.touch.destroyable.owner",
                                             Components.blank(),
                                             getComponentName(),
                                             getOwner().getComponentName());
        } else if(self) {
            return new TranslatableComponent("match.touch.destroyable.owner.you",
                                             Components.blank(),
                                             getComponentName(),
                                             getOwner().getComponentName());
        } else {
            return new TranslatableComponent("match.touch.destroyable.owner.toucher",
                                             toucher.getStyledName(NameStyle.COLOR),
                                             getComponentName(),
                                             getOwner().getComponentName());
        }
    }

    @Override
    public Iterable<Location> getProximityLocations(ParticipantState player) {
        if(proximityLocations == null) {
            proximityLocations = Collections.singleton(getBlockRegion().getBounds().center().toLocation(getOwner().getMatch().getWorld()));
        }
        return proximityLocations;
    }

    void addMaterials(MaterialPattern pattern) {
        materialPatterns.add(pattern);
        if(pattern.dataMatters()) {
            materials.add(pattern.getMaterialData());
        } else {
            // Hacky, but there is no other simple way to deal with block replacement
            materials.addAll(NMSHacks.getBlockStates(pattern.getMaterial()));
        }
    }

    /**
     * Calculate maximum/current health
     */
    protected void recalculateHealth() {
        // We only need blockMaterialHealth if there are destroyable blocks that are
        // replaced by other destroyable blocks when broken.
        if(this.isAffectedByBlockReplacementRules()) {
            this.blockMaterialHealth = new HashMap<>();
            this.buildMaterialHealthMap();
        } else {
            this.blockMaterialHealth = null;
            this.maxHealth = (int) this.blockRegion.blockVolume();
            this.health = 0;
            for(Block block : this.blockRegion.getBlocks(match.getWorld())) {
                if(this.hasMaterial(block.getState().getData())) {
                    this.health++;
                }
            }
        }
    }

    protected boolean isAffectedByBlockReplacementRules() {
        if (this.blockDropsRuleSet.getRules().isEmpty()) {
            return false;
        }

        for(Block block : this.blockRegion.getBlocks(match.getWorld())) {
            for(MaterialData material : this.materials) {
                BlockDrops drops = this.blockDropsRuleSet.getDrops(block.getState(), material);
                if(drops != null && drops.replacement != null && this.hasMaterial(drops.replacement)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Used internally to break out of the below recursive algorithm when a cycle is detected
     */
    protected final static class Indestructible extends Exception { }

    protected void buildMaterialHealthMap() {
        this.maxHealth = 0;
        this.health = 0;
        Set<MaterialData> visited = new HashSet<>();
        try {
            for(Block block : blockRegion.getBlocks(match.getWorld())) {
                Map<MaterialData, Integer> materialHealthMap = new HashMap<>();
                int blockMaxHealth = 0;

                for(MaterialData material : this.materials) {
                    visited.clear();
                    int blockHealth = this.buildBlockMaterialHealthMap(block, material, materialHealthMap, visited);
                    if(blockHealth > blockMaxHealth) {
                        blockMaxHealth = blockHealth;
                    }
                }

                this.blockMaterialHealth.put(block.getLocation().toVector().toBlockVector(), materialHealthMap);
                this.maxHealth += blockMaxHealth;
                this.health += this.getBlockHealth(block.getState());
            }
        }
        catch(Indestructible ex) {
            this.health = this.maxHealth = Integer.MAX_VALUE;
            PGM.get().getLogger().warning("Destroyable " + this.getName() + " is indestructible due to block replacement cycle");
        }
    }

    protected int buildBlockMaterialHealthMap(Block block,
                                              MaterialData material,
                                              Map<MaterialData, Integer> materialHealthMap,
                                              Set<MaterialData> visited) throws Indestructible {

        if(!this.hasMaterial(material)) {
            return 0;
        }

        Integer healthBoxed = materialHealthMap.get(material);
        if(healthBoxed != null) {
            return healthBoxed;
        }

        if(visited.contains(material)) {
            throw new Indestructible();
        }
        visited.add(material);

        int health = 1;
        if(this.blockDropsRuleSet != null) {
            BlockDrops drops = this.blockDropsRuleSet.getDrops(block.getState(), material);
            if(drops != null && drops.replacement != null) {
                health += this.buildBlockMaterialHealthMap(block, drops.replacement, materialHealthMap, visited);
            }
        }

        materialHealthMap.put(material, health);
        return health;
    }

    /**
     * Return the number of breaks required to change the given block to a non-objective material
     */
    public int getBlockHealth(BlockState blockState) {
        if(!this.getBlockRegion().contains(blockState)) {
            return 0;
        }

        if(this.blockMaterialHealth == null) {
            return this.hasMaterial(blockState.getData()) ? 1 : 0;
        } else {
            Map<MaterialData, Integer> materialHealthMap = this.blockMaterialHealth.get(blockState.getLocation().toVector().toBlockVector());
            if(materialHealthMap == null) {
                return 0;
            }
            Integer health = materialHealthMap.get(blockState.getData());
            return health == null ? 0 : health;
        }
    }

    public int getBlockHealthChange(BlockState oldState, BlockState newState) {
        return this.getBlockHealth(newState) - this.getBlockHealth(oldState);
    }

    /**
     * Update the state of this Destroyable to reflect the given block being changed by the given player.
     * @param oldState State of the block before the change
     * @param newState State of the block after the change
     * @param player Player responsible for the change
     * @return An object containing information about the change, including the health delta,
     *         or null if this Destroyable was not affected by the block change
     */
    public DestroyableHealthChange handleBlockChange(BlockState oldState, BlockState newState, @Nullable ParticipantState player) {
        if(this.isDestroyed() || !this.getBlockRegion().contains(oldState)) return null;

        int deltaHealth = this.getBlockHealthChange(oldState, newState);
        if(deltaHealth == 0) return null;

        this.addHealth(deltaHealth);

        DestroyableHealthChange changeInfo = new DestroyableHealthChange(oldState, newState, player, deltaHealth);
        this.events.add(changeInfo);

        if(deltaHealth < 0) {
            touch(player);

            if(this.definition.hasSparks()) {
                Location blockLocation = BlockUtils.center(oldState);
                Instant now = Instant.now();

                // Probability of a spark is time_since_last_spark / cooldown_time
                float chance = this.lastSparkTime == null ? 1.0f : ((float) Duration.between(lastSparkTime, now).toMillis()) / (float) SPARK_COOLDOWN.toMillis();
                if(this.match.getRandom().nextFloat() < chance) {
                    this.lastSparkTime = now;

                    // Spawn a firework where the block was
                    Firework firework = FireworkUtil.spawnFirework(blockLocation,
                                                                   FireworkEffect.builder()
                                                                       .with(FireworkEffect.Type.BURST)
                                                                       .withFlicker()
                                                                       .withColor(this.getOwner().getFullColor())
                                                                       .build(),
                                                                   0);
                    NMSHacks.skipFireworksLaunch(firework);

                    // Players more than 64m away will not see or hear the fireworks, so just play the sound for them
                    for(MatchPlayer listener : this.getOwner().getMatch().getPlayers()) {
                        if(listener.getBukkit().getLocation().distance(blockLocation) > 64) {
                            listener.getBukkit().playSound(listener.getBukkit().getLocation(), Sound.ENTITY_FIREWORK_BLAST, 0.75f, 1f);
                            listener.getBukkit().playSound(listener.getBukkit().getLocation(), Sound.ENTITY_FIREWORK_TWINKLE, 0.75f, 1f);
                        }
                    }
                }
            }
        }

        match.callEvent(new DestroyableHealthChangeEvent(this.getMatch(), this, changeInfo));
        match.callEvent(new GoalStatusChangeEvent(this));

        if(this.isDestroyed()) {
            match.callEvent(new DestroyableDestroyedEvent(this.match, this));
            match.callEvent(new GoalCompleteEvent(this,
                                                  true,
                                                  c -> false,
                                                  c -> !c.equals(getOwner()),
                                                  this.getContributions()));
        }

        return changeInfo;
    }

    @Override
    protected void playTouchEffects(ParticipantState toucher) {
        // We make our own touch sounds
    }

    /**
     * Test if the given block change is allowed by this Destroyable
     * @param oldState State of the block before the change
     * @param newState State of the block after the change
     * @param player Player responsible for the change
     * @return A player-readable message explaining why the block change is not allowed, or null if it is allowed
     */
    public String testBlockChange(BlockState oldState, BlockState newState, @Nullable ParticipantState player) {
        if(this.isDestroyed() || !this.getBlockRegion().contains(oldState)) return null;

        int deltaHealth = this.getBlockHealthChange(oldState, newState);
        if(deltaHealth == 0) return null;

        if(deltaHealth < 0) {
            // Damage
            if(player != null && player.getParty() == this.getOwner()) {
                return "match.destroyable.damageOwn";
            }
        } else if(deltaHealth > 0) {
            // Repair
            if(player != null && player.getParty() != this.getOwner()) {
                return "match.destroyable.repairOther";
            } else if(!this.definition.isRepairable()) {
                return "match.destroyable.repairDisabled";
            }
        }

        return null;
    }

    public FiniteBlockRegion getBlockRegion() {
        return this.blockRegion;
    }

    public boolean hasMaterial(MaterialData data) {
        for(MaterialPattern material : materialPatterns) {
            if(material.matches(data)) return true;
        }
        return false;
    }

    public void addHealth(int delta) {
        this.health = Math.max(0, Math.min(this.maxHealth, this.health + delta));
    }

    public int getMaxHealth() {
        return this.maxHealth;
    }

    public int getHealth() {
        return this.health;
    }

    public float getHealthPercent() {
        return (float) this.health / this.maxHealth;
    }

    public int getBreaks() {
        return this.maxHealth - this.health;
    }

    @Override
    public boolean isAffectedByModeChanges() {
        return this.definition.hasModeChanges();
    }

    public double getDestructionRequired() {
        return this.destructionRequired;
    }

    public String renderDestructionRequired() {
        return Math.round(this.destructionRequired * 100) + "%";
    }

    private int getBreaksRequired(double destructionRequired) {
        return (int) Math.round(this.maxHealth * destructionRequired);
    }

    public int getBreaksRequired() {
        return this.getBreaksRequired(this.destructionRequired);
    }

    public void setDestructionRequired(double destructionRequired) {
        if(this.destructionRequired != destructionRequired) {
            if(this.getBreaks() >= this.getBreaksRequired(destructionRequired)) {
                throw new IllegalArgumentException("Destroyable is already destroyed that much");
            } else if(destructionRequired > 1) {
                throw new IllegalArgumentException("Cannot require more than 100% destruction");
            }

            this.destructionRequired = destructionRequired;
            this.getOwner().getMatch().getPluginManager().callEvent(new FeatureChangeEvent(this.getMatch(), this));
        }
    }

    public void setBreaksRequired(int breaks) {
        this.setDestructionRequired((double) breaks / this.getMaxHealth());
    }

    @Override
    public double getCompletion() {
        return (double) this.getBreaks() / this.getBreaksRequired();
    }

    @Override
    public String renderCompletion() {
        return Strings.progressPercentage(this.getCompletion());
    }

    @Override
    public String renderPreciseCompletion() {
        return this.getBreaks() + "/" + this.getBreaksRequired();
    }

    @Override
    public String renderSidebarStatusText(@Nullable Competitor competitor, Party viewer) {
        if(this.getShowProgress() || Parties.isObservingType(viewer)) {
            String text = this.renderCompletion();
            if(Config.Scoreboard.preciseProgress()) {
                String precise = this.renderPreciseCompletion();
                if(precise != null) {
                    text += " " + ChatColor.GRAY + precise;
                }
            }
            return text;
        } else {
            return super.renderSidebarStatusText(competitor, viewer);
        }
    }

    @Override
    public boolean getShowProgress() {
        return this.definition.getShowProgress();
    }

    public boolean isDestroyed() {
        return this.getBreaks() >= this.getBreaksRequired();
    }

    @Override
    public boolean canComplete(Competitor team) {
        return team != this.getOwner();
    }

    @Override
    public boolean isCompleted() {
        return this.isDestroyed();
    }

    @Override
    public boolean isCompleted(Competitor team) {
        return this.isDestroyed() && this.canComplete(team);
    }

    public @Nonnull List<DestroyableHealthChange> getEvents() {
        return ImmutableList.copyOf(this.events);
    }

    public @Nonnull ImmutableList<DestroyableContribution> getContributions() {
        if(this.contributions != null) {
            return this.contributions;
        }

        Map<MatchPlayerState, Integer> playerDamage = new DefaultMapAdapter<>(new HashMap<MatchPlayerState, Integer>(), 0);

        int totalDamage = 0;
        for(DestroyableHealthChange change : this.events) {
            if(change.getHealthChange() < 0) {
                MatchPlayerState player = change.getPlayerCause();
                if(player != null) {
                    playerDamage.put(player, playerDamage.get(player) - change.getHealthChange());
                }
                totalDamage -= change.getHealthChange();
            }
        }

        ImmutableList.Builder<DestroyableContribution> builder = ImmutableList.builder();
        for(Map.Entry<MatchPlayerState, Integer> entry : playerDamage.entrySet()) {
            builder.add(new DestroyableContribution(
                entry.getKey(),
                (double) entry.getValue() / totalDamage, entry.getValue()
            ));
        }

        ImmutableList<DestroyableContribution> contributions = builder.build();
        if(this.isDestroyed()) {
            // Only cache if completely destroyed
            this.contributions = contributions;
        }
        return contributions;
    }

    @SuppressWarnings("deprecation")
    @Override
    public void replaceBlocks(MaterialData newMaterial) {
        // Calling this method causes all non-destroyed blocks to be replaced, and the material
        // list to be replaced with one containing only the new block. If called on a multi-stage
        // destroyable, i.e. one which is affected by block replacement rules, it effectively ceases
        // to be multi-stage. Even if there are block replacement rules for the new block, the
        // replacements will not be in the material list, and so those blocks will be considered
        // destroyed the first time they are mined. This can have some strange effects on the health
        // of the destroyable: individual block health can only decrease, while the total health
        // percentage can only increase.
        double oldCompletion = getCompletion();

        for (Block block : this.getBlockRegion().getBlocks(match.getWorld())) {
            BlockState oldState = block.getState();
            int oldHealth = this.getBlockHealth(oldState);

            if (oldHealth > 0) {
                block.setTypeIdAndData(newMaterial.getItemTypeId(), newMaterial.getData(), true);
            }
        }

        // Update the materials list on switch
        this.materialPatterns.clear();
        this.materials.clear();
        addMaterials(new MaterialPattern(newMaterial));

        // If there is a block health map, get rid of it, since there is now only one material in the list
        this.blockMaterialHealth = null;

        this.recalculateHealth();

        if(oldCompletion != getCompletion()) {
            match.callEvent(new DestroyableHealthChangeEvent(match, this, null));
        }
    }

    @Override
    public boolean isObjectiveMaterial(Block block) {
        return this.hasMaterial(block.getState().getData());
    }

    @Override
    public String getModeChangeMessage() {
        return "match.objectiveMode.name.destroyable";
    }

    @Override
    public MatchDoc.Destroyable getDocument() {
        return new Document();
    }

    class Document extends TouchableGoal.Document implements MatchDoc.Destroyable {
        @Override
        public int total_blocks() {
            return getMaxHealth();
        }

        @Override
        public int breaks_required() {
            return getBreaksRequired();
        }

        @Override
        public int breaks() {
            return getBreaks();
        }

        @Override
        public double completion() {
            return getCompletion();
        }
    }
}