package org.allaymc.api.scoreboard;

import com.google.common.collect.Sets;
import lombok.Getter;
import org.allaymc.api.eventbus.event.scoreboard.ScoreboardAllLinesRemoveEvent;
import org.allaymc.api.eventbus.event.scoreboard.ScoreboardLineAddEvent;
import org.allaymc.api.eventbus.event.scoreboard.ScoreboardLineRemoveEvent;
import org.allaymc.api.scoreboard.data.DisplaySlot;
import org.allaymc.api.scoreboard.data.SortOrder;
import org.allaymc.api.scoreboard.scorer.FakeScorer;
import org.allaymc.api.scoreboard.scorer.Scorer;
import org.allaymc.api.server.Server;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * Scoreboard is a set of {@link ScoreboardLine}.
 */
@Getter
public final class Scoreboard {
    /**
     * The identifier name of this scoreboard
     */
    private final String objectiveName;
    /**
     * The display name of this scoreboard
     */
    private String displayName;
    /**
     * The "criteria" of this scoreboard (e.g., dummy)
     */
    private String criteriaName;
    private final Map<DisplaySlot, Set<ScoreboardViewer>> viewers = new ConcurrentHashMap<>();
    private final Map<Scorer, ScoreboardLine> lines = new ConcurrentHashMap<>();
    /**
     * The sorting rule of this scoreboard
     * <p>
     * The sorting action is performed by the client, so the server does not need to handle sorting
     */
    private SortOrder sortOrder;

    public Scoreboard(String objectiveName) {
        this(objectiveName, objectiveName);
    }

    public Scoreboard(String objectiveName, String displayName) {
        this(objectiveName, displayName, "dummy");
    }

    public Scoreboard(String objectiveName, String displayName, String criteriaName) {
        this(objectiveName, displayName, criteriaName, SortOrder.ASCENDING);
    }

    public Scoreboard(String objectiveName, String displayName, String criteriaName, SortOrder sortOrder) {
        this.objectiveName = objectiveName;
        this.displayName = displayName;
        this.criteriaName = criteriaName;
        this.sortOrder = sortOrder;
        for (var slot : DisplaySlot.values()) {
            viewers.put(slot, Sets.newConcurrentHashSet());
        }
    }

    /**
     * @return All viewers of this scoreboard
     */
    public Set<ScoreboardViewer> getAllViewers() {
        var all = new HashSet<ScoreboardViewer>();
        this.viewers.values().forEach(all::addAll);
        return all;
    }

    /**
     * @param slot Target slot
     * @return Viewers of the target slot of this scoreboard
     */
    public Set<ScoreboardViewer> getViewers(DisplaySlot slot) {
        return this.viewers.get(slot);
    }

    /**
     * Remove a specific viewer from the target slot of this scoreboard
     *
     * @param viewer Target viewer
     * @param slot   Target slot
     * @return Whether the removal was successful
     */
    public boolean removeViewer(ScoreboardViewer viewer, DisplaySlot slot) {
        var removed = this.viewers.get(slot).remove(viewer);
        if (removed) viewer.hideScoreboardSlot(slot);
        return removed;
    }

    /**
     * Add a viewer to the target slot of this scoreboard
     *
     * @param viewer Target viewer
     * @param slot   Target slot
     * @return Whether the addition was successful
     */
    public boolean addViewer(ScoreboardViewer viewer, DisplaySlot slot) {
        removeInvalidViewers();
        var added = this.viewers.get(slot).add(viewer);
        if (added) viewer.displayScoreboard(this, slot);
        return added;
    }

    /**
     * remove invalid viewers（eg: offline player）
     */
    private void removeInvalidViewers() {
        this.viewers.forEach((slot, slotViewers) -> {
            var invalid = slotViewers.stream()
                    .filter(viewer -> !viewer.isScoreboardViewerValid())
                    .collect(Collectors.toSet());
            slotViewers.removeAll(invalid);
        });
    }

    /**
     * Check if a specific viewer exists in the target slot of this scoreboard
     *
     * @param viewer Target viewer
     * @param slot   Target slot
     * @return Whether it exists
     */
    public boolean containViewer(ScoreboardViewer viewer, DisplaySlot slot) {
        return this.viewers.get(slot).contains(viewer);
    }

    /**
     * Set the display name of this scoreboard.
     * <p>
     * If this scoreboard is being displayed, the changes will be resent to viewers immediately.
     *
     * @param displayName New display name
     */
    public void setDisplayName(String displayName) {
        this.displayName = displayName;
        resend();
    }

    /**
     * Set the criteria name of this scoreboard.
     * <p>
     * If this scoreboard is being displayed, the changes will be resent to viewers immediately.
     *
     * @param criteriaName New criteria name
     */
    public void setCriteriaName(String criteriaName) {
        this.criteriaName = criteriaName;
        resend();
    }

    /**
     * Set the sort order of this scoreboard.
     * <p>
     * If this scoreboard is being displayed, the changes will be resent to viewers immediately.
     *
     * @param sortOrder New sort order
     */
    public void setSortOrder(SortOrder sortOrder) {
        this.sortOrder = sortOrder;
        resend();
    }

    /**
     * Get the line corresponding to the tracker in this scoreboard (if it exists)
     *
     * @param scorer Tracker
     * @return Corresponding line
     */
    public ScoreboardLine getLine(Scorer scorer) {
        return this.lines.get(scorer);
    }

    /**
     * Add a line to this scoreboard
     *
     * @param line Target line
     * @return Whether the addition was successful
     */
    public boolean addLine(ScoreboardLine line) {
        if (wouldCallEvent()) {
            var event = new ScoreboardLineAddEvent(this, line);
            if (!event.call()) {
                return false;
            }
        }
        this.lines.put(line.getScorer(), line);
        updateScore(line);
        return true;
    }

    /**
     * Add a line to this scoreboard
     *
     * @param scorer Tracker
     * @param score  Score
     * @return Whether the addition was successful
     */
    public boolean addLine(Scorer scorer, int score) {
        return addLine(new ScoreboardLine(this, scorer, score));
    }

    /**
     * Convenient interface for plugins to display scores
     *
     * @param text  Name of FakeScorer
     * @param score Score
     * @return Whether the addition was successful
     */
    public boolean addLine(String text, int score) {
        var fakeScorer = new FakeScorer(text);
        return addLine(new ScoreboardLine(this, fakeScorer, score));
    }

    /**
     * Remove the line corresponding to the tracker in this scoreboard (if it exists)
     *
     * @param scorer Target tracker
     * @return Whether the removal was successful
     */
    public boolean removeLine(Scorer scorer) {
        if (lines.isEmpty()) return false;

        var removed = lines.get(scorer);
        if (removed == null) return false;

        if (wouldCallEvent()) {
            var event = new ScoreboardLineRemoveEvent(this, removed);
            if (!event.call()) {
                return false;
            }
        }
        this.lines.remove(scorer);
        getAllViewers().forEach(viewer -> viewer.removeScoreboardLine(removed));
        return true;
    }

    /**
     * Remove all lines from the scoreboard
     *
     * @param send Whether to send to viewers
     * @return Whether the removal was successful
     */
    public boolean removeAllLines(boolean send) {
        if (lines.isEmpty()) return false;
        if (wouldCallEvent()) {
            var event = new ScoreboardAllLinesRemoveEvent(this);
            if (!event.call()) {
                return false;
            }
        }
        if (send) this.lines.keySet().forEach(this::removeLine);
        else this.lines.clear();
        return true;
    }

    /**
     * Check if the tracker has a record in this scoreboard
     *
     * @param scorer Target tracker
     * @return Whether it exists
     */
    public boolean containLine(Scorer scorer) {
        return this.lines.containsKey(scorer);
    }

    /**
     * Send new scores to all viewers <br>
     *
     * @param update Line to update
     */
    public void updateScore(ScoreboardLine update) {
        getAllViewers().forEach(viewer -> viewer.updateScore(update));
    }

    /**
     * Resend this scoreboard and line information to all viewers
     * <p>
     * For example, after making a lot of changes to the scoreboard, call this method
     * <p>
     * It can save a lot of bandwidth
     */
    public void resend() {
        getAllViewers().forEach(viewer -> viewer.removeScoreboard(this));

        this.viewers.forEach((slot, slotViewers) ->
                slotViewers.forEach(slotViewer ->
                        slotViewer.displayScoreboard(this, slot)
                )
        );
    }

    /**
     * Convenient interface for plugins
     * <p>
     * Set the content of the scoreboard in the order of the List (using FakeScorer as the tracker)
     * <p>
     * It will overwrite all previous lines
     *
     * @param lines String content to set
     */
    public void setLines(List<String> lines) {
        removeAllLines(false);
        AtomicInteger score = new AtomicInteger();
        lines.forEach(str -> {
            var scorer = new FakeScorer(str);
            this.lines.put(scorer, new ScoreboardLine(this, scorer, score.getAndIncrement()));
        });
        resend();
    }

    /**
     * Set the content of the scoreboard in the order of the List <br>
     * It will overwrite all previous lines <br>
     *
     * @param lines Line content to set
     */
    public void setLines(Collection<ScoreboardLine> lines) {
        removeAllLines(false);
        lines.forEach(line -> this.lines.put(line.getScorer(), line));
        resend();
    }

    /**
     * @return Whether changes to this scoreboard will trigger events
     */
    public boolean wouldCallEvent() {
        return Server.getInstance().getScoreboardManager().contain(this);
    }
}
