package net.citizensnpcs.trait;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Display.Billboard;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.Transformation;
import org.joml.Vector3d;

import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;

import net.citizensnpcs.Settings.Setting;
import net.citizensnpcs.api.CitizensAPI;
import net.citizensnpcs.api.command.Arg.CompletionsProvider;
import net.citizensnpcs.api.command.CommandContext;
import net.citizensnpcs.api.npc.MemoryNPCDataStore;
import net.citizensnpcs.api.npc.NPC;
import net.citizensnpcs.api.npc.NPCRegistry;
import net.citizensnpcs.api.persistence.Persist;
import net.citizensnpcs.api.trait.Trait;
import net.citizensnpcs.api.trait.TraitName;
import net.citizensnpcs.api.util.DataKey;
import net.citizensnpcs.api.util.Messaging;
import net.citizensnpcs.api.util.Placeholders;
import net.citizensnpcs.api.util.SpigotUtil;
import net.citizensnpcs.util.NMS;
import net.citizensnpcs.util.Util;

/**
 * Manages a set of <em>holograms</em> attached to the NPC. Holograms are lines of text or items that follow the NPC at
 * some offset (typically vertically offset).
 */
// TODO: cleanup
@TraitName("hologramtrait")
public class HologramTrait extends Trait {
    private Location currentLoc;
    private double lastEntityBbHeight = 0;
    private boolean lastNameplateVisible;
    @Persist
    private double lineHeight = -1;
    private final List<HologramLine> lines = Lists.newArrayList();
    private HologramLine nameLine;
    private final NPCRegistry registry = CitizensAPI.createCitizensBackedNPCRegistry(new MemoryNPCDataStore());
    private int t;
    @Persist
    private int viewRange = -1;

    public HologramTrait() {
        super("hologramtrait");
    }

    /**
     * Adds a new hologram line which will displayed over an NPC's head.
     *
     * @param text
     *            The new line to add
     */
    public void addLine(String text) {
        lines.add(new HologramLine(text, true));
        reloadLineHolograms();
    }

    public void addLine(String text, HologramRenderer hr) {
        lines.add(new HologramLine(text, hr));
        reloadLineHolograms();
    }

    /**
     * Adds a new hologram line which will displayed over an NPC's head. It will not persist to disk and will last for
     * the specified amount of ticks.
     *
     * @param text
     *            The new line to add
     * @param ticks
     *            The number of ticks to last for
     */
    public void addTemporaryLine(String text, int ticks) {
        lines.add(new HologramLine(text, false, ticks));
        reloadLineHolograms();
    }

    /**
     * Clears all hologram lines
     */
    public void clear() {
        for (HologramLine line : lines) {
            line.removeNPC();
        }
        lines.clear();
    }

    private double getEntityBbHeight() {
        return NMS.getBoundingBoxHeight(npc.getEntity());
    }

    private double getHeight(int lineNumber) {
        double base = lastNameplateVisible ? 0 : -getLineHeight();
        for (int i = 0; i <= lineNumber; i++) {
            HologramLine line = lines.get(i);
            base += line.mb + getLineHeight();
            if (i != lineNumber) {
                base += line.mt;
            }
        }
        return base;
    }

    /**
     * @return The line height between each hologram line, in blocks
     */
    public double getLineHeight() {
        return lineHeight == -1 ? Setting.DEFAULT_NPC_HOLOGRAM_LINE_HEIGHT.asDouble() : lineHeight;
    }

    /**
     * @return the hologram lines, in bottom-up order
     */
    public List<String> getLines() {
        return Lists.transform(lines, l -> l.text);
    }

    public int getViewRange() {
        return viewRange;
    }

    @Override
    public void load(DataKey root) {
        clear();
        for (DataKey key : root.getRelative("lines").getIntegerSubKeys()) {
            HologramLine line = new HologramLine(key.keyExists("text") ? key.getString("text") : key.getString(""),
                    true);
            line.mt = key.keyExists("margin.top") ? key.getDouble("margin.top") : 0.0;
            line.mb = key.keyExists("margin.bottom") ? key.getDouble("margin.bottom") : 0.0;
            lines.add(line);
        }
    }

    @Override
    public void onDespawn() {
        reloadLineHolograms();
    }

    @Override
    public void onRemove() {
        onDespawn();
    }

    @Override
    public void onSpawn() {
        if (!npc.isSpawned())
            return;

        lastNameplateVisible = Boolean
                .parseBoolean(npc.data().<Object> get(NPC.Metadata.NAMEPLATE_VISIBLE, true).toString());
    }

    private void reloadLineHolograms() {
        for (HologramLine line : lines) {
            line.removeNPC();
        }
        if (nameLine != null) {
            nameLine.removeNPC();
            nameLine = null;
        }
        currentLoc = null;
    }

    /**
     * Removes the line at the specified index
     *
     * @param idx
     */
    public void removeLine(int idx) {
        if (idx < 0 || idx >= lines.size())
            return;

        lines.remove(idx).removeNPC();

        reloadLineHolograms();
    }

    @Override
    public void run() {
        if (!npc.isSpawned()) {
            onDespawn();
            return;
        }
        boolean nameplateVisible = Boolean
                .parseBoolean(npc.data().<Object> get(NPC.Metadata.NAMEPLATE_VISIBLE, true).toString());
        if (npc.requiresNameHologram()) {
            if (nameLine != null && !nameplateVisible) {
                nameLine.removeNPC();
                nameLine = null;
            } else if (nameLine == null && nameplateVisible) {
                nameLine = new HologramLine(npc.getRawName(),
                        SUPPORTS_DISPLAY ? new InteractionVehicleRenderer() : new ArmorstandVehicleRenderer());
            }
        }
        Location npcLoc = npc.getStoredLocation();
        Vector3d offset = new Vector3d();
        boolean updatePosition = Setting.HOLOGRAM_ALWAYS_UPDATE_POSITION.asBoolean() || currentLoc == null
                || currentLoc.getWorld() != npcLoc.getWorld() || currentLoc.distance(npcLoc) >= 0.001
                || lastNameplateVisible != nameplateVisible
                || Math.abs(lastEntityBbHeight - getEntityBbHeight()) >= 0.05;
        boolean updateName = false;

        if (t++ >= Setting.HOLOGRAM_UPDATE_RATE.asTicks() + Util.getFastRandom().nextInt(3) /* add some jitter */) {
            t = 0;
            updateName = true;
        }
        lastNameplateVisible = nameplateVisible;

        if (updatePosition) {
            currentLoc = npcLoc.clone();
            lastEntityBbHeight = getEntityBbHeight();
        }
        if (nameLine != null) {
            if (updatePosition) {
                nameLine.render(offset);
            }
            if (updateName) {
                nameLine.setText(npc.getRawName());
            }
        }
        for (int i = 0; i < lines.size(); i++) {
            HologramLine line = lines.get(i);

            if (line.ticks > 0 && --line.ticks == 0) {
                lines.remove(i--).removeNPC();
                continue;
            }
            if (updatePosition) {
                offset.y = getHeight(i);
                line.render(offset);
            }
            if (updateName) {
                line.setText(line.text);
            }
        }
    }

    @Override
    public void save(DataKey root) {
        root.removeKey("lines");
        int i = 0;
        for (HologramLine line : lines) {
            if (!line.persist)
                continue;

            root.setString("lines." + i + ".text", line.text);
            root.setDouble("lines." + i + ".margin.top", line.mt);
            root.setDouble("lines." + i + ".margin.bottom", line.mb);
            i++;
        }
    }

    /**
     * Sets the hologram line at a specific index
     *
     * @param idx
     *            The index
     * @param text
     *            The new line
     */
    public void setLine(int idx, String text) {
        if (idx == lines.size()) {
            addLine(text);
            return;
        }
        HologramLine line = lines.get(idx);
        line.setText(text);
        reloadLineHolograms();
    }

    /**
     * Sets the line height
     *
     * @see #getLineHeight()
     * @param height
     *            The line height in blocks
     */
    public void setLineHeight(double height) {
        lineHeight = height;
        reloadLineHolograms();
    }

    /**
     * Sets the margin of a line at a specific index
     *
     * @param idx
     *            The line index
     * @param type
     *            The margin type, top or bottom
     * @param margin
     *            The margin
     */
    public void setMargin(int idx, String type, double margin) {
        if (type.equalsIgnoreCase("top")) {
            lines.get(idx).mt = margin;
        } else if (type.equalsIgnoreCase("bottom")) {
            lines.get(idx).mb = margin;
        }
        reloadLineHolograms();
    }

    public void setViewRange(int range) {
        this.viewRange = range;
        reloadLineHolograms();
    }

    public class ArmorstandRenderer extends SingleEntityHologramRenderer {
        @Override
        protected NPC createNPC(Entity base, String name, Vector3d offset) {
            NPC npc = registry.createNPC(EntityType.ARMOR_STAND, name);
            npc.getOrAddTrait(ArmorStandTrait.class).setAsHelperEntityWithName(npc);
            return npc;
        }

        @Override
        protected void render0(NPC npc, Vector3d offset) {
            hologram.getEntity().teleport(
                    npc.getStoredLocation().clone().add(offset.x, offset.y + getEntityBbHeight(), offset.z),
                    TeleportCause.PLUGIN);
        }
    }

    public class ArmorstandVehicleRenderer extends ArmorstandRenderer {
        @Override
        protected NPC createNPC(Entity base, String name, Vector3d offset) {
            NPC npc = registry.createNPC(EntityType.ARMOR_STAND, name);
            npc.getOrAddTrait(ArmorStandTrait.class).setAsHelperEntityWithName(npc);
            return npc;
        }

        @Override
        public void render0(NPC base, Vector3d offset) {
            if (hologram.getEntity().getVehicle() == null) {
                base.getEntity().addPassenger(hologram.getEntity());
            }
        }
    }

    private class HologramLine {
        double mb, mt;
        boolean persist;
        HologramRenderer renderer;
        String text;
        int ticks;

        public HologramLine(String text, boolean persist) {
            this(text, persist, -1,
                    SUPPORTS_DISPLAY && Setting.DEFAULT_HOLOGRAM_RENDERER.asString().equalsIgnoreCase("interaction")
                            ? new InteractionVehicleRenderer()
                            : SUPPORTS_DISPLAY
                                    && Setting.DEFAULT_HOLOGRAM_RENDERER.asString().equalsIgnoreCase("display")
                                            ? new TextDisplayVehicleRenderer()
                                            : new ArmorstandRenderer());
        }

        public HologramLine(String text, boolean persist, int ticks) {
            this(text, persist, ticks,
                    SUPPORTS_DISPLAY && Setting.DEFAULT_HOLOGRAM_RENDERER.asString().equalsIgnoreCase("interaction")
                            ? new InteractionVehicleRenderer()
                            : SUPPORTS_DISPLAY
                                    && Setting.DEFAULT_HOLOGRAM_RENDERER.asString().equalsIgnoreCase("display")
                                            ? new TextDisplayVehicleRenderer()
                                            : new ArmorstandRenderer());
        }

        public HologramLine(String text, boolean persist, int ticks, HologramRenderer hr) {
            if (ITEM_MATCHER.matcher(text).matches()) {
                mb = 0.21;
                mt = 0.07;
                hr = new ItemRenderer();
            }
            this.persist = persist;
            this.ticks = ticks;
            this.renderer = hr;
            setText(text);
        }

        public HologramLine(String text, HologramRenderer renderer) {
            this(text, false, -1, renderer);
        }

        public void removeNPC() {
            renderer.destroy();
        }

        public void render(Vector3d vector3d) {
            renderer.render(npc, vector3d);
        }

        public void setText(String text) {
            this.text = text == null ? "" : text;
            if (ITEM_MATCHER.matcher(text).matches()) {
                renderer.destroy();
                mb = 0.21;
                mt = 0.07;
                renderer = new ItemRenderer();
            }
            renderer.updateText(npc, text);
        }
    }

    public static interface HologramRenderer {
        void destroy();

        String getPerPlayerText(NPC npc, Player viewer);

        default boolean isSneaking(NPC npc, Player player) {
            return NMS.isSneaking(player);
        }

        default void onSeenByPlayer(Player player) {
        }

        void render(NPC npc, Vector3d offset);

        void updateText(NPC npc, String text);
    }

    public class InteractionVehicleRenderer extends SingleEntityHologramRenderer {
        private Vector3d lastOffset;

        @Override
        protected NPC createNPC(Entity base, String name, Vector3d offset) {
            lastOffset = new Vector3d(offset);
            return registry.createNPC(EntityType.INTERACTION, name);
        }

        @Override
        public void onSeenByPlayer(Player player) {
            if (lastOffset == null)
                return;
            NMS.linkTextInteraction(player, hologram.getEntity(), npc.getEntity(), lastOffset.y);
        }

        @Override
        public void render0(NPC npc, Vector3d offset) {
            lastOffset = new Vector3d(offset);
            if (hologram.getEntity().getVehicle() == null) {
                npc.getEntity().addPassenger(hologram.getEntity());
            }
        }
    }

    public class ItemRenderer extends SingleEntityHologramRenderer {
        @Override
        protected NPC createNPC(Entity base, String name, Vector3d offset) {
            Matcher itemMatcher = ITEM_MATCHER.matcher(name);
            Material item = SpigotUtil.isUsing1_13API() ? Material.matchMaterial(itemMatcher.group(1), false)
                    : Material.matchMaterial(itemMatcher.group(1));
            ItemStack itemStack = new ItemStack(item, 1);
            NPC npc = registry.createNPCUsingItem(EntityType.DROPPED_ITEM, "", itemStack);
            npc.data().setPersistent(NPC.Metadata.NAMEPLATE_VISIBLE, false);
            if (itemMatcher.group(2) != null) {
                if (itemMatcher.group(2).charAt(1) == '{') {
                    Bukkit.getUnsafe().modifyItemStack(itemStack, itemMatcher.group(2).substring(1));
                    npc.setItemProvider(() -> itemStack);
                } else {
                    npc.getOrAddTrait(ScoreboardTrait.class)
                            .setColor(Util.matchEnum(ChatColor.values(), itemMatcher.group(2).substring(1)));
                }
            }
            return npc;
        }

        @Override
        protected void render0(NPC npc, Vector3d offset) {
            hologram.getEntity().teleport(npc.getStoredLocation().clone().add(offset.x, offset.y, offset.z),
                    TeleportCause.PLUGIN);
        }
    }

    public abstract class SingleEntityHologramRenderer implements HologramRenderer {
        protected NPC hologram;
        protected String text;

        protected abstract NPC createNPC(Entity base, String text, Vector3d offset);

        @Override
        public void destroy() {
            if (hologram != null) {
                hologram.destroy();
                hologram = null;
            }
        }

        @Override
        public String getPerPlayerText(NPC npc, Player viewer) {
            return Placeholders.replace(text, viewer, npc);
        }

        @Override
        public void render(NPC npc, Vector3d offset) {
            if (hologram == null) {
                spawnHologram(npc, offset);
            }
            render0(npc, offset);
        }

        protected abstract void render0(NPC npc, Vector3d offset);

        protected void spawnHologram(NPC npc, Vector3d offset) {
            hologram = createNPC(npc.getEntity(), text, offset);
            if (!hologram.hasTrait(ClickRedirectTrait.class)) {
                hologram.addTrait(new ClickRedirectTrait(npc));
            }
            hologram.data().set(NPC.Metadata.HOLOGRAM_RENDERER, this);
            if (Setting.PACKET_HOLOGRAMS.asBoolean()) {
                hologram.addTrait(PacketNPC.class);
            }
            if (viewRange != -1) {
                hologram.data().set(NPC.Metadata.TRACKING_RANGE, viewRange);
            } else if (npc.data().has(NPC.Metadata.TRACKING_RANGE)) {
                hologram.data().set(NPC.Metadata.TRACKING_RANGE, npc.data().get(NPC.Metadata.TRACKING_RANGE));
            }
            hologram.spawn(npc.getEntity().getLocation().add(offset.x, offset.y, offset.z));
        }

        @Override
        public void updateText(NPC npc, String text) {
            this.text = Placeholders.replace(text, null, npc);
            if (hologram == null)
                return;
            hologram.setName(text);
            if (!Placeholders.containsPlaceholders(text)) {
                hologram.data().set(NPC.Metadata.NAMEPLATE_VISIBLE, Messaging.stripColor(text).length() > 0);
            }
        }
    }

    public static class TabCompletions implements CompletionsProvider {
        @Override
        public Collection<String> getCompletions(CommandContext args, CommandSender sender, NPC npc) {
            if (args.length() > 1 && npc != null && LINE_ARGS.contains(args.getString(1).toLowerCase())) {
                HologramTrait ht = npc.getOrAddTrait(HologramTrait.class);
                return IntStream.range(0, ht.getLines().size()).mapToObj(Integer::toString)
                        .collect(Collectors.toList());
            }
            return Collections.emptyList();
        }

        private static Set<String> LINE_ARGS = ImmutableSet.of("set", "remove", "margintop", "marginbottom");
    }

    public class TextDisplayVehicleRenderer extends SingleEntityHologramRenderer {
        @Override
        protected NPC createNPC(Entity base, String name, Vector3d offset) {
            NPC npc = registry.createNPC(EntityType.TEXT_DISPLAY, name);
            return npc;
        }

        @Override
        public void render0(NPC base, Vector3d offset) {
            TextDisplay disp = (TextDisplay) hologram.getEntity();
            disp.setBillboard(Billboard.CENTER);
            Transformation tf = disp.getTransformation();
            tf.getTranslation().y = (float) offset.y + 0.1f;
            disp.setTransformation(tf);
            if (hologram.getEntity().getVehicle() == null) {
                base.getEntity().addPassenger(hologram.getEntity());
            }
        }
    }

    private static final Pattern ITEM_MATCHER = Pattern.compile("<item:(.*?)([:].*?)?>");
    private static boolean SUPPORTS_DISPLAY = false;
    static {
        try {
            SUPPORTS_DISPLAY = Class.forName("org.bukkit.entity.Display") != null;
        } catch (Throwable e) {
        }
    }
}
