package tc.oc.pgm.teams;

import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import java.util.function.ToIntFunction;
import javax.annotation.Nullable;
import javax.inject.Inject;

import com.google.common.collect.Sets;
import com.google.inject.assistedinject.Assisted;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.chat.BaseComponent;
import org.apache.commons.lang.math.Fraction;
import org.bukkit.command.CommandSender;
import java.time.Duration;
import tc.oc.api.docs.PlayerId;
import tc.oc.api.docs.virtual.MatchDoc;
import tc.oc.commons.bukkit.chat.NameStyle;
import tc.oc.commons.core.chat.ChatUtils;
import tc.oc.commons.core.chat.Component;
import tc.oc.commons.core.util.Comparables;
import tc.oc.commons.core.util.Numbers;
import tc.oc.commons.core.util.PunchClock;
import tc.oc.pgm.events.PartyRenameEvent;
import tc.oc.pgm.features.SluggedFeature;
import tc.oc.pgm.join.JoinConfiguration;
import tc.oc.pgm.join.JoinDenied;
import tc.oc.pgm.join.JoinHandler;
import tc.oc.pgm.join.JoinMatchModule;
import tc.oc.pgm.join.JoinResult;
import tc.oc.pgm.match.Competitor;
import tc.oc.pgm.match.Match;
import tc.oc.pgm.match.MatchPlayer;
import tc.oc.pgm.match.MultiPlayerParty;
import tc.oc.pgm.teams.events.TeamResizeEvent;

/** Mutable class to represent a team created from a TeamInfo instance that is
 * tied to a specific match and will only live as long as the match lives.
 * Teams support custom names and colors that differ from the defaults
 * specified by the map creator.
 */
public class Team extends MultiPlayerParty implements Competitor, SluggedFeature<TeamFactory> {

    // The maximum allowed ratio between the "fullness" of any two teams in a match,
    // as measured by the Team.getFullness method. An imbalance of one player is
    // always allowed, even if it exceeds this ratio.
    public static final Fraction MAX_IMBALANCE = Fraction.getFraction(6, 5);

    public interface Factory {
        Team create(TeamFactory definition);
    }

    private final TeamFactory info;
    private final JoinConfiguration joinConfiguration;
    private final TeamConfiguration teamConfiguration;

    private TeamMatchModule tmm;
    protected @Nullable String name = null;
    protected @Nullable Component componentName;
    protected Component chatPrefix;
    protected Optional<Integer> minPlayers = Optional.empty(),
                                maxPlayers = Optional.empty(),
                                maxOverfill = Optional.empty();
    protected final Document document = new Document();
    protected final Set<PlayerId> pastPlayers = new HashSet<>();

    // Recorded in the match document, Tourney plugin sets this
    protected @Nullable String leagueTeamId;

    // Players who have ever been on this team and their participation/absence times
    protected final PunchClock<PlayerId> participationClock = new PunchClock<>(getMatch()::runningTime);

    /** Construct a Team instance with the necessary information.
     * @param info Defaults to use for name and color.
     * @param match Match this team is in.
     * @param joinConfiguration
     * @param teamConfiguration
     */
    @Inject Team(@Assisted TeamFactory info, Match match, JoinConfiguration joinConfiguration, TeamConfiguration teamConfiguration) {
        super(match);
        this.info = info;
        this.joinConfiguration = joinConfiguration;
        this.teamConfiguration = teamConfiguration;
    }

    @Override
    public String toString() {
        return getClass().getSimpleName() + "{match=" + getMatch() + ", name=" + getName() + "}";
    }

    @Override
    public boolean equals(Object that) {
        return this == that || (that instanceof Team &&
                                getDefinition().equals(((Team) that).getDefinition()) &&
                                getMatch().equals(((Team) that).getMatch()));
    }

    @Override
    public int hashCode() {
        return Objects.hash(getDefinition(), getMatch());
    }

    protected TeamMatchModule module() {
        if(tmm == null) {
            tmm = getMatch().needMatchModule(TeamMatchModule.class);
        }
        return tmm;
    }

    @Override
    public String getId() {
        return slug();
    }

    @Override
    public String slug() {
        return match.featureDefinitions().slug(getDefinition());
    }

    /** Gets map specified information about this team.
     * @return Map-specific information about the team.
     */
    public TeamFactory getInfo() {
        return this.info;
    }

    public @Nullable String getLeagueTeamId() {
        return leagueTeamId;
    }

    public void setLeagueTeamId(@Nullable String leagueTeamId) {
        this.leagueTeamId = leagueTeamId;
    }

    @Override
    public MatchDoc.Team getDocument() {
        return document;
    }

    @Override
    public TeamFactory getDefinition() {
        return this.info;
    }

    @Override
    public Type getType() {
        return Type.Participating;
    }

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

    @Override
    public boolean isParticipating() {
        return match.isRunning();
    }

    @Override
    public boolean isObservingType() {
        return false;
    }

    @Override
    public boolean isObserving() {
        return !match.isRunning();
    }

    @Override
    public String getDefaultName() {
        return info.getDefaultName();
    }

    /** Gets the name of this team that can be modified using setTeam.  If no
     * custom name is set then this will return the default team name as
     * specified in the team info.
     * @return Name of the team without colors.
     */
    @Override
    public String getName() {
        return name != null ? name : getDefaultName();
    }

    public String getShortName() {
        String lower = getName().toLowerCase();
        if(lower.endsWith(" team")) {
            return getName().substring(0, lower.length() - " team".length());
        } else if(lower.startsWith("team ")) {
            return getName().substring("team ".length());
        } else {
            return getName();
        }
    }

    @Override
    public String getName(@Nullable CommandSender viewer) {
        return getName();
    }

    @Override
    public boolean isNamePlural() {
        // Assume custom names are singular
        return this.name == null && this.info.isDefaultNamePlural();
    }

    /** Gets the combination of the team color with the team name.
     * @return Colored version of the team name.
     */
    @Override
    public String getColoredName() {
        return getColor() + getName();
    }

    @Override
    public String getColoredName(@Nullable CommandSender viewer) {
        return getColor() + getName(viewer);
    }

    /** Sets a custom name for this team that should be unique in the match.
     * Note that setting the name to null will reset it to the default name as
     * specified in the team info.
     * @param newName New name for this team.  Should not include colors.
     */
    public void setName(@Nullable String newName) {
        if(Objects.equals(this.name, newName) || this.getName().equals(newName)) return;
        String oldName = this.getName();
        this.name = newName;
        this.componentName = null;
        this.match.callEvent(new PartyRenameEvent(this, oldName, this.getName()));
    }

    @Override
    public ChatColor getColor() {
        return this.info.getDefaultColor();
    }

    @Override
    public BaseComponent getComponentName() {
        if(componentName == null) {
            this.componentName = new Component(getName(), ChatUtils.convert(getColor()));
        }
        return componentName;
    }

    @Override
    public BaseComponent getStyledName(NameStyle style) {
        return getComponentName();
    }

    @Override
    public BaseComponent getChatPrefix() {
        if(chatPrefix == null) {
            this.chatPrefix = new Component("[" + getShortName() + "] ", ChatUtils.convert(getColor()));
        }
        return chatPrefix;
    }

    @Override
    public org.bukkit.scoreboard.Team.OptionStatus getNameTagVisibility() {
        return info.getNameTagVisibility();
    }

    public int getMinPlayers() {
        return minPlayers.orElse(info.getMinPlayers().orElse(teamConfiguration.minimumPlayers()));
    }

    public int getMaxPlayers() {
        return maxPlayers.orElse(info.getMaxPlayers());
    }

    public int getMaxOverfill() {
        return maxOverfill.orElse(info.getMaxOverfill().orElse(joinConfiguration.overfillFromMax(getMaxPlayers())));
    }

    public void setMinSize(@Nullable Integer minPlayers) {
        this.minPlayers = Optional.ofNullable(minPlayers);

        if(getMaxPlayers() < getMinPlayers()) {
            this.maxPlayers = Optional.of(getMinPlayers());
        }
        if(getMaxOverfill() < getMaxPlayers()) {
            this.maxOverfill = Optional.of(getMaxPlayers());
        }

        getMatch().callEvent(new TeamResizeEvent(this));
        module().updatePlayerLimits();
    }

    public void resetMinSize() {
        setMinSize(null);
    }

    public void setMaxSize(@Nullable Integer maxPlayers, @Nullable Integer maxOverfill) {
        this.maxPlayers = Optional.ofNullable(maxPlayers);
        this.maxOverfill = Optional.ofNullable(maxOverfill);

        if(getMinPlayers() > getMaxPlayers()) {
            this.minPlayers = Optional.of(getMaxPlayers());
        }
        if(getMaxOverfill() < getMaxPlayers()) {
            this.maxOverfill = Optional.of(getMaxPlayers());
        }

        getMatch().callEvent(new TeamResizeEvent(this));
        module().updatePlayerLimits();
    }

    public void resetMaxSize() {
        setMaxSize(null, null);
    }

    public PunchClock<PlayerId> getParticipationClock() {
        return participationClock;
    }

    public Duration getCumulativeParticipation(PlayerId playerId) {
        return getParticipationClock().getCumulativePresence(playerId);
    }

    @Override
    public Set<PlayerId> getPastPlayers() {
        return pastPlayers;
    }

    @Override
    public boolean addPlayerInternal(MatchPlayer player) {
        if(super.addPlayerInternal(player)) {
            participationClock.punchIn(player.getPlayerId());
            if(getMatch().isCommitted()) {
                pastPlayers.add(player.getPlayerId());
            }
            return true;
        } else {
            return false;
        }
    }

    @Override
    public boolean removePlayerInternal(MatchPlayer player) {
        if(super.removePlayerInternal(player)) {
            participationClock.punchOut(player.getPlayerId());
            return true;
        } else {
            return false;
        }
    }

    @Override
    public void commit() {
        for(MatchPlayer player : getPlayers()) {
            pastPlayers.add(player.getPlayerId());
        }
    }

    @Override
    public boolean isAutomatic() {
        return false;
    }

    protected @Nullable MatchPlayer joiningPlayer() {
        final Change change = CHANGE.get();
        return change != null && equals(change.newTeam) ? change.player : null;
    }

    @Override
    public Set<MatchPlayer> getPlayers() {
        final Change change = CHANGE.get();
        if(change != null) {
            if(equals(change.oldTeam)) {
                return Sets.difference(super.getPlayers(), Collections.singleton(change.player));
            } else if(equals(change.newTeam)) {
                return Sets.union(super.getPlayers(), Collections.singleton(change.player));
            }
        }
        return super.getPlayers();
    }

    /**
     * Return the number of players on this team.
     * If priority is true, exclude players who can be bumped off the team.
     */
    public int getSize() {
        final int realSize = super.getPlayers().size();
        final Change change = CHANGE.get();
        if(change != null) {
            if(equals(change.oldTeam)) {
                return realSize - 1;
            } else if(equals(change.newTeam)) {
                return realSize + 1;
            }
        }
        return realSize;
    }

    /**
     * Get the "fullness" of this team, relative to some capacity returned by
     * the given function. The return value is always in the range 0 to 1.
     */
    public Fraction getFullness(ToIntFunction<? super Team> maxFunction) {
        final int max = maxFunction.applyAsInt(this);
        return max == 0 ? Fraction.ONE
                        : Fraction.getReducedFraction(getSize(), max);
    }

    /**
     * Get the maximum number of players currently allowed on this team without
     * exceeding any limits.
     */
    public int getMaxBalancedSize() {
        // Find the minimum fullness among other teams
        final Fraction minFullness = (Fraction) module().getTeams()
                                                        .stream()
                                                        .filter(team -> !equals(team))
                                                        .map(team -> team.getFullness(Team::getMaxOverfill))
                                                        .min(Comparator.naturalOrder())
                                                        .orElse(Fraction.ONE);

        // Calculate the dynamic limit to maintain balance with other teams (this can be zero)
        int slots = Numbers.ceil(Comparables.min(Fraction.ONE, minFullness.multiplyBy(MAX_IMBALANCE))
                                            .multiplyBy(Fraction.getFraction(getMaxOverfill(), 1)));

        // Clamp to the static limit defined for this team (cannot be zero unless the static limit is zero)
        return Math.min(getMaxOverfill(), Math.max(1, slots));
    }

    public boolean isStacked() {
        return this.getSize() > this.getMaxBalancedSize();
    }

    public int getTotalSlots(MatchPlayer joining) {
        return getMatch().needMatchModule(JoinMatchModule.class).canJoinFull(joining) ? getMaxOverfill() : getMaxPlayers();
    }

    /**
     * Return the number of available slots for the given player. If priority is true,
     * and the joining player has priority kick privileges, assume that non-privileged
     * players can be kicked off the team to make room.
     */
    public int getOpenSlots(MatchPlayer joining, boolean priorityKick) {
        // Can always join obs
        if(this.getType() == Type.Observing) return 1;

        // Count existing team members with and without join privileges
        int normal = 0, privileged = 0;

        final JoinMatchModule jmm = getMatch().needMatchModule(JoinMatchModule.class);

        for(MatchPlayer player : this.getPlayers()) {
            if(jmm.canPriorityKick(player)) privileged++;
            else normal++;
        }

        // Get the total slots available to the joining player
        // Deduct slots in use by privileged players, who cannot be kicked
        int slots = getTotalSlots(joining) - privileged;

        // If normal players cannot be bumped, deduct them as well
        if(!priorityKick || !jmm.canPriorityKick(joining)) {
            slots -= normal;
        }

        return Math.max(0, slots);
    }

    /**
     * @return if there is a free slot available for the given player to join this team.
     *         If the player is already on this team, the test behaves as if they are not.
     */
    public boolean hasOpenSlots(MatchPlayer joining, boolean priorityKick) {
        return this.getOpenSlots(joining, priorityKick) > 0;
    }

    /**
     * Perform a join query specific to this team, similar to {@link JoinHandler#queryJoin}.
     *
     * @param joining         Player who is joining
     * @param priorityKick    If false, priority kicking is not considered at all.
     *                        If true, priority kicking is considered if the player has that privilege.
     * @param rejoin          If true, assume the player was previously on this team and is rejoining.
     *
     * @return The result of the hypothetical join
     */
    public JoinResult queryJoin(MatchPlayer joining, boolean priorityKick, boolean rejoin) {
        if(hasOpenSlots(joining, false)) {
            return new JoinTeam(this, rejoin, false);
        }

        if(priorityKick && hasOpenSlots(joining, true)) {
            return new JoinTeam(this, rejoin, true);
        }

        return JoinDenied.unavailable("command.gameplay.join.completelyFull", getComponentName());
    }

    // TODO: send an update to the API when any of these values change
    class Document implements MatchDoc.Team {
        @Override
        public String _id() {
            return slug();
        }

        @Override
        public String name() {
            return getName();
        }

        @Override
        public @Nullable Integer min_players() {
            return getMinPlayers();
        }

        @Override
        public @Nullable Integer max_players() {
            return getMaxPlayers();
        }

        @Override
        public @Nullable net.md_5.bungee.api.ChatColor color() {
            return ChatUtils.convert(getColor());
        }

        @Override
        public @Nullable Integer size() {
            return getParticipationClock().getAllWithPresence().size();
        }

        @Override
        public String league_team_id() {
            return leagueTeamId;
        }
    }

    /**
     * Run the given block with a temporary team change in effect for all team queries and calculations.
     * All {@link Team}s will behave as if the given player has left their current team, if any, and
     * joined the given team, if its non-null.
     *
     * If the given player is null, or the specified change is not actually a change, the block is
     * run without altering any behavior. Whatever the block returns is returned by this method.
     *
     * This mechanism affects {@link #getPlayers()}, {@link #getSize()}, and any other method derived
     * from those, which includes all the methods involved in joining and balancing teams. This is
     * useful for performing calculations about some hypothetical state, without actually changing
     * the current state.
     *
     * Internally, this works by storing the change in a static {@link ThreadLocal}, which is checked
     * by various methods. If a change is present, they adjust their behavior to reflect the change.
     * The effects of this method are only visible while it is executing, and only to the current thread.
     *
     * Only one change can be simulated at a time on any particular thread. Calling this method again
     * from within the block will throw a {@link IllegalStateException}.
     */
    public static <R> R withChange(@Nullable MatchPlayer player, @Nullable Team newTeam, Supplier<R> block) {
        if(player == null || Objects.equals(player.partyMaybe().orElse(null), newTeam)) {
            return block.get();
        } else {
            if(CHANGE.get() != null) {
                throw new IllegalStateException("Nested call to Team.withChange");
            }
            try {
                CHANGE.set(new Change(player, Teams.get(player), newTeam));
                return block.get();
            } finally {
                CHANGE.remove();
            }
        }
    }

    private static final ThreadLocal<Change> CHANGE = new ThreadLocal<>();

    private static class Change {
        final MatchPlayer player;
        final @Nullable Team oldTeam;
        final @Nullable Team newTeam;

        private Change(MatchPlayer player, Team oldTeam, Team newTeam) {
            this.player = player;
            this.oldTeam = oldTeam;
            this.newTeam = newTeam;
        }
    }
}
