/*
 * This file is part of helper, licensed under the MIT License.
 *
 *  Copyright (c) lucko (Luck) <[email protected]>
 *  Copyright (c) contributors
 *
 *  Permission is hereby granted, free of charge, to any person obtaining a copy
 *  of this software and associated documentation files (the "Software"), to deal
 *  in the Software without restriction, including without limitation the rights
 *  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 *  copies of the Software, and to permit persons to whom the Software is
 *  furnished to do so, subject to the following conditions:
 *
 *  The above copyright notice and this permission notice shall be included in all
 *  copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 *  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 *  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 *  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 *  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 *  SOFTWARE.
 */

package me.lucko.helper.messaging.bungee;

import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import com.google.common.io.ByteArrayDataInput;
import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams;

import me.lucko.helper.Schedulers;
import me.lucko.helper.plugin.HelperPlugin;
import me.lucko.helper.promise.Promise;
import me.lucko.helper.terminable.composite.CompositeTerminable;
import me.lucko.helper.utils.Players;
import me.lucko.helper.utils.annotation.NonnullByDefault;

import org.bukkit.entity.Player;
import org.bukkit.plugin.messaging.PluginMessageListener;

import java.io.ByteArrayInputStream;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;

import javax.annotation.Nullable;

@NonnullByDefault
public final class BungeeCordImpl implements BungeeCord, PluginMessageListener {

    /*
     * See:
     * - https://www.spigotmc.org/wiki/bukkit-bungee-plugin-messaging-channel
     * - https://github.com/SpigotMC/BungeeCord/blob/master/proxy/src/main/java/net/md_5/bungee/connection/DownstreamBridge.java#L223
     */

    /**
     * The name of the BungeeCord plugin channel
     */
    private static final String CHANNEL = "BungeeCord";

    /**
     * The plugin instance
     */
    private final HelperPlugin plugin;

    /**
     * If the listener has been registered
     */
    private final AtomicBoolean setup = new AtomicBoolean(false);

    /**
     * The registered listeners
     */
    private final List<MessageCallback> listeners = new LinkedList<>();

    /**
     * Lock to guard the 'listeners' list
     */
    private final ReentrantLock lock = new ReentrantLock();

    /**
     * Messages to be sent
     */
    private final Set<MessageAgent> queuedMessages = ConcurrentHashMap.newKeySet();

    public BungeeCordImpl(HelperPlugin plugin) {
        this.plugin = plugin;
    }

    private void ensureSetup() {
        if (!this.setup.compareAndSet(false, true)) {
            return;
        }

        this.plugin.getServer().getMessenger().registerOutgoingPluginChannel(this.plugin, CHANNEL);
        this.plugin.getServer().getMessenger().registerIncomingPluginChannel(this.plugin, CHANNEL, this);

        this.plugin.bind(CompositeTerminable.create()
                .with(() -> {
                    this.plugin.getServer().getMessenger().unregisterOutgoingPluginChannel(this.plugin, CHANNEL);
                    this.plugin.getServer().getMessenger().unregisterIncomingPluginChannel(this.plugin, CHANNEL, this);
                })
                .with(Schedulers.builder()
                        .sync()
                        .afterAndEvery(3, TimeUnit.SECONDS)
                        .run(this::flushQueuedMessages)
                )
        );
    }

    @Override
    public void onPluginMessageReceived(String channel, Player player, byte[] data) {
        if (!channel.equals(CHANNEL)) {
            return;
        }

        // create an input stream from the recieved data
        ByteArrayInputStream byteIn = new ByteArrayInputStream(data);

        // create a data input instance
        ByteArrayDataInput in = ByteStreams.newDataInput(byteIn);

        // read the subchannel & mark the beginning of the stream at this point, so we can reset to this position later
        String subChannel = in.readUTF();
        byteIn.mark(/* ignored */ 0);

        // pass the incoming message to all registered listeners
        this.lock.lock();
        try {
            Iterator<MessageCallback> it = this.listeners.iterator();
            while (it.hasNext()) {
                MessageCallback e = it.next();

                // check if the subchannel is valid
                if (!e.getSubChannel().equals(subChannel)) {
                    continue;
                }

                // reset the inputstream to the start position
                byteIn.reset();

                // test if the data should be "passed" to the callback
                boolean accepted = e.testResponse(player, in);
                if (!accepted) {
                    continue;
                }

                // reset again
                byteIn.reset();

                // pass the data to the callback
                boolean shouldRemove = e.acceptResponse(player, in);
                if (shouldRemove) {
                    it.remove();
                }
            }
        } finally {
            this.lock.unlock();
        }
    }

    /**
     * Sends (or queues the sending of) the message encapsulated by the given message agent
     *
     * @param agent the agent
     */
    private void sendMessage(MessageAgent agent) {
        // check if the agent has a specific player handle to use when sending the message
        Player player = agent.getHandle();
        if (player != null) {
            if (!player.isOnline()) {
                throw new IllegalStateException("Player not online");
            }

            sendToChannel(agent, player);
            return;
        }

        // try to find a player
        player = Iterables.getFirst(Players.all(), null);
        if (player != null) {
            sendToChannel(agent, player);
        } else {
            // no players online, queue the message
            this.queuedMessages.add(agent);
            ensureSetup();
        }
    }

    private void flushQueuedMessages() {
        if (this.queuedMessages.isEmpty()) {
            return;
        }

        Player p = Iterables.getFirst(Players.all(), null);
        if (p != null) {
            this.queuedMessages.removeIf(ma -> {
                sendToChannel(ma, p);
                return true;
            });
        }
    }

    private void sendToChannel(MessageAgent agent, Player player) {
        ensureSetup();

        // create a new data output stream for the message
        ByteArrayDataOutput out = ByteStreams.newDataOutput();

        // write the channel
        out.writeUTF(agent.getSubChannel());
        // append the agents data
        agent.appendPayload(out);

        byte[] buf = out.toByteArray();
        player.sendPluginMessage(this.plugin, CHANNEL, buf);

        // if the agent is also a MessageCallback, register it
        if (agent instanceof MessageCallback) {
            MessageCallback callback = (MessageCallback) agent;
            registerCallback(callback);
        }
    }

    private void registerCallback(MessageCallback callback) {
        ensureSetup();

        this.lock.lock();
        try {
            this.listeners.add(callback);
        } finally {
            this.lock.unlock();
        }
    }

    @Override
    public void connect(Player player, String serverName) {
        sendMessage(new ConnectAgent(player, serverName));
    }

    @Override
    public void connectOther(String playerName, String serverName) {
        sendMessage(new ConnectOtherAgent(playerName, serverName));
    }

    @Override
    public Promise<Map.Entry<String, Integer>> ip(Player player) {
        Promise<Map.Entry<String, Integer>> fut = Promise.empty();
        sendMessage(new IPAgent(player, fut));
        return fut;
    }

    @Override
    public Promise<Integer> playerCount(String serverName) {
        Promise<Integer> fut = Promise.empty();
        sendMessage(new PlayerCountAgent(serverName, fut));
        return fut;
    }

    @Override
    public Promise<List<String>> playerList(String serverName) {
        Promise<List<String>> fut = Promise.empty();
        sendMessage(new PlayerListAgent(serverName, fut));
        return fut;
    }

    @Override
    public Promise<List<String>> getServers() {
        Promise<List<String>> fut = Promise.empty();
        sendMessage(new GetServersAgent(fut));
        return fut;
    }

    @Override
    public void message(String playerName, String message) {
        sendMessage(new PlayerMessageAgent(playerName, message));
    }

    @Override
    public Promise<String> getServer() {
        Promise<String> fut = Promise.empty();
        sendMessage(new GetServerAgent(fut));
        return fut;
    }

    @Override
    public Promise<UUID> uuid(Player player) {
        Promise<UUID> fut = Promise.empty();
        sendMessage(new UUIDAgent(player, fut));
        return fut;
    }

    @Override
    public Promise<UUID> uuidOther(String playerName) {
        Promise<UUID> fut = Promise.empty();
        sendMessage(new UUIDOtherAgent(playerName, fut));
        return fut;
    }

    @Override
    public Promise<Map.Entry<String, Integer>> serverIp(String serverName) {
        Promise<Map.Entry<String, Integer>> fut = Promise.empty();
        sendMessage(new ServerIPAgent(serverName, fut));
        return fut;
    }

    @Override
    public void kickPlayer(String playerName, String reason) {
        sendMessage(new KickPlayerAgent(playerName, reason));
    }

    @Override
    public void forward(String serverName, String channelName, byte[] data) {
        sendMessage(new ForwardAgent(serverName, channelName, data));
    }

    @Override
    public void forward(String serverName, String channelName, ByteArrayDataOutput data) {
        sendMessage(new ForwardAgent(serverName, channelName, data));
    }

    @Override
    public void forwardToPlayer(String playerName, String channelName, byte[] data) {
        sendMessage(new ForwardToPlayerAgent(playerName, channelName, data));
    }

    @Override
    public void forwardToPlayer(String playerName, String channelName, ByteArrayDataOutput data) {
        sendMessage(new ForwardToPlayerAgent(playerName, channelName, data));
    }

    @Override
    public void registerForwardCallbackRaw(String channelName, Predicate<byte[]> callback) {
        ForwardCustomCallback customCallback = new ForwardCustomCallback(channelName, callback);
        registerCallback(customCallback);
    }

    @Override
    public void registerForwardCallback(String channelName, Predicate<ByteArrayDataInput> callback) {
        final Predicate<ByteArrayDataInput> cb = Objects.requireNonNull(callback, "callback");
        ForwardCustomCallback customCallback = new ForwardCustomCallback(channelName, bytes -> cb.test(ByteStreams.newDataInput(bytes)));
        registerCallback(customCallback);
    }

    /**
     * Responsible for writing data to the output stream when the message is to be sent
     */
    private interface MessageAgent {

        /**
         * Gets the sub channel this message should be sent using
         *
         * @return the message channel
         */
        String getSubChannel();

        /**
         * Gets the player to send the message via
         *
         * @return the player to send the message via, or null if any player should be used
         */
        @Nullable
        default Player getHandle() {
            return null;
        }

        /**
         * Appends the data for this message to the output stream
         *
         * @param out the output stream
         */
        default void appendPayload(ByteArrayDataOutput out) {

        }
    }

    /**
     * Responsible for monitoring incoming messages, and passing on the callback data if applicable
     */
    private interface MessageCallback {

        /**
         * Gets the sub channel this callback is listening for
         *
         * @return the message channel
         */
        String getSubChannel();

        /**
         * Returns true if the incoming data applies to this callback
         *
         * @param receiver the player instance which received the data
         * @param in the input data
         * @return true if the data is applicable
         */
        default boolean testResponse(Player receiver, ByteArrayDataInput in) {
            return true;
        }

        /**
         * Accepts the incoming data, and returns true if this callback should now be de-registered
         *
         * @param receiver the player instance which received the data
         * @param in the input data
         * @return if the callback should be de-registered
         */
        boolean acceptResponse(Player receiver, ByteArrayDataInput in);

    }

    private static final class ConnectAgent implements MessageAgent {
        private static final String CHANNEL = "Connect";

        private final Player player;
        private final String serverName;

        private ConnectAgent(Player player, String serverName) {
            this.player = Objects.requireNonNull(player, "player");
            this.serverName = Objects.requireNonNull(serverName, "serverName");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public Player getHandle() {
            return this.player;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.serverName);
        }
    }

    private static final class ConnectOtherAgent implements MessageAgent {
        private static final String CHANNEL = "ConnectOther";

        private final String playerName;
        private final String serverName;

        private ConnectOtherAgent(String playerName, String serverName) {
            this.playerName = Objects.requireNonNull(playerName, "playerName");
            this.serverName = Objects.requireNonNull(serverName, "serverName");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.playerName);
            out.writeUTF(this.serverName);
        }
    }

    private static final class IPAgent implements MessageAgent, MessageCallback {
        private static final String CHANNEL = "IP";

        private final Player player;
        private final Promise<Map.Entry<String, Integer>> callback;

        private IPAgent(Player player, Promise<Map.Entry<String, Integer>> callback) {
            this.player = Objects.requireNonNull(player, "player");
            this.callback = Objects.requireNonNull(callback, "callback");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public Player getHandle() {
            return this.player;
        }

        @Override
        public boolean testResponse(Player receiver, ByteArrayDataInput in) {
            return receiver.getUniqueId().equals(this.player.getUniqueId());
        }

        @Override
        public boolean acceptResponse(Player receiver, ByteArrayDataInput in) {
            String ip = in.readUTF();
            int port = in.readInt();
            this.callback.supply(Maps.immutableEntry(ip, port));
            return true;
        }
    }

    private static final class PlayerCountAgent implements MessageAgent, MessageCallback {
        private static final String CHANNEL = "PlayerCount";

        private final String serverName;
        private final Promise<Integer> callback;

        private PlayerCountAgent(String serverName, Promise<Integer> callback) {
            this.serverName = Objects.requireNonNull(serverName, "serverName");
            this.callback = Objects.requireNonNull(callback, "callback");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.serverName);
        }

        @Override
        public boolean testResponse(Player receiver, ByteArrayDataInput in) {
            return in.readUTF().equalsIgnoreCase(this.serverName);
        }

        @Override
        public boolean acceptResponse(Player receiver, ByteArrayDataInput in) {
            in.readUTF();
            int count = in.readInt();
            this.callback.supply(count);
            return true;
        }
    }

    private static final class PlayerListAgent implements MessageAgent, MessageCallback {
        private static final String CHANNEL = "PlayerList";

        private final String serverName;
        private final Promise<List<String>> callback;

        private PlayerListAgent(String serverName, Promise<List<String>> callback) {
            this.serverName = Objects.requireNonNull(serverName, "serverName");
            this.callback = Objects.requireNonNull(callback, "callback");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.serverName);
        }

        @Override
        public boolean testResponse(Player receiver, ByteArrayDataInput in) {
            return in.readUTF().equalsIgnoreCase(this.serverName);
        }

        @Override
        public boolean acceptResponse(Player receiver, ByteArrayDataInput in) {
            in.readUTF();
            String csv = in.readUTF();

            if (csv.isEmpty()) {
                this.callback.supply(ImmutableList.of());
                return true;
            }

            this.callback.supply(ImmutableList.copyOf(Splitter.on(", ").splitToList(csv)));
            return true;
        }
    }

    private static final class GetServersAgent implements MessageAgent, MessageCallback {
        private static final String CHANNEL = "GetServers";
        
        private final Promise<List<String>> callback;

        private GetServersAgent(Promise<List<String>> callback) {
            this.callback = Objects.requireNonNull(callback, "callback");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public boolean acceptResponse(Player receiver, ByteArrayDataInput in) {
            String csv = in.readUTF();

            if (csv.isEmpty()) {
                this.callback.supply(ImmutableList.of());
                return true;
            }

            this.callback.supply(ImmutableList.copyOf(Splitter.on(", ").splitToList(csv)));
            return true;
        }
    }

    private static final class PlayerMessageAgent implements MessageAgent {
        private static final String CHANNEL = "Message";

        private final String playerName;
        private final String message;

        private PlayerMessageAgent(String playerName, String message) {
            this.playerName = Objects.requireNonNull(playerName, "playerName");
            this.message = Objects.requireNonNull(message, "message");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.playerName);
            out.writeUTF(this.message);
        }
    }

    private static final class GetServerAgent implements MessageAgent, MessageCallback {
        private static final String CHANNEL = "GetServer";

        private final Promise<String> callback;

        private GetServerAgent(Promise<String> callback) {
            this.callback = Objects.requireNonNull(callback, "callback");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public boolean acceptResponse(Player receiver, ByteArrayDataInput in) {
            this.callback.supply(in.readUTF());
            return true;
        }
    }

    private static final class UUIDAgent implements MessageAgent, MessageCallback {
        private static final String CHANNEL = "UUID";

        private final Player player;
        private final Promise<UUID> callback;

        private UUIDAgent(Player player, Promise<UUID> callback) {
            this.player = Objects.requireNonNull(player, "player");
            this.callback = Objects.requireNonNull(callback, "callback");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public Player getHandle() {
            return this.player;
        }

        @Override
        public boolean testResponse(Player receiver, ByteArrayDataInput in) {
            return receiver.getUniqueId().equals(this.player.getUniqueId());
        }

        @Override
        public boolean acceptResponse(Player receiver, ByteArrayDataInput in) {
            String uuid = in.readUTF();
            this.callback.supply(UUID.fromString(uuid));
            return true;
        }
    }

    private static final class UUIDOtherAgent implements MessageAgent, MessageCallback {
        private static final String CHANNEL = "UUIDOther";

        private final String playerName;
        private final Promise<UUID> callback;

        private UUIDOtherAgent(String playerName, Promise<UUID> callback) {
            this.playerName = Objects.requireNonNull(playerName, "playerName");
            this.callback = Objects.requireNonNull(callback, "callback");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.playerName);
        }

        @Override
        public boolean testResponse(Player receiver, ByteArrayDataInput in) {
            return in.readUTF().equalsIgnoreCase(this.playerName);
        }

        @Override
        public boolean acceptResponse(Player receiver, ByteArrayDataInput in) {
            in.readUTF();
            String uuid = in.readUTF();
            this.callback.supply(UUID.fromString(uuid));
            return true;
        }
    }

    private static final class ServerIPAgent implements MessageAgent, MessageCallback {
        private static final String CHANNEL = "ServerIP";

        private final String serverName;
        private final Promise<Map.Entry<String, Integer>> callback;

        private ServerIPAgent(String serverName, Promise<Map.Entry<String, Integer>> callback) {
            this.serverName = Objects.requireNonNull(serverName, "serverName");
            this.callback = Objects.requireNonNull(callback, "callback");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.serverName);
        }

        @Override
        public boolean testResponse(Player receiver, ByteArrayDataInput in) {
            return in.readUTF().equalsIgnoreCase(this.serverName);
        }

        @Override
        public boolean acceptResponse(Player receiver, ByteArrayDataInput in) {
            in.readUTF();
            String ip = in.readUTF();
            int port = in.readInt();
            this.callback.supply(Maps.immutableEntry(ip, port));
            return true;
        }
    }

    private static final class KickPlayerAgent implements MessageAgent {
        private static final String CHANNEL = "KickPlayer";

        private final String playerName;
        private final String reason;

        private KickPlayerAgent(String playerName, String reason) {
            this.playerName = Objects.requireNonNull(playerName, "playerName");
            this.reason = Objects.requireNonNull(reason, "reason");
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.playerName);
            out.writeUTF(this.reason);
        }
    }

    private static final class ForwardAgent implements MessageAgent {
        private static final String CHANNEL = "Forward";

        private final String serverName;
        private final String channelName;
        private final byte[] data;

        private ForwardAgent(String serverName, String channelName, byte[] data) {
            this.serverName = Objects.requireNonNull(serverName, "serverName");
            this.channelName = Objects.requireNonNull(channelName, "channelName");
            this.data = data;
        }

        private ForwardAgent(String serverName, String channelName, ByteArrayDataOutput data) {
            this.serverName = Objects.requireNonNull(serverName, "serverName");
            this.channelName = Objects.requireNonNull(channelName, "channelName");
            this.data = Objects.requireNonNull(data, "data").toByteArray();
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.serverName);
            out.writeUTF(this.channelName);
            out.writeShort(this.data.length);
            out.write(this.data);
        }
    }

    private static final class ForwardToPlayerAgent implements MessageAgent {
        private static final String CHANNEL = "ForwardToPlayer";

        private final String playerName;
        private final String channelName;
        private final byte[] data;

        private ForwardToPlayerAgent(String playerName, String channelName, byte[] data) {
            this.playerName = Objects.requireNonNull(playerName, "playerName");
            this.channelName = Objects.requireNonNull(channelName, "channelName");
            this.data = data;
        }

        private ForwardToPlayerAgent(String playerName, String channelName, ByteArrayDataOutput data) {
            this.playerName = Objects.requireNonNull(playerName, "playerName");
            this.channelName = Objects.requireNonNull(channelName, "channelName");
            this.data = Objects.requireNonNull(data, "data").toByteArray();
        }

        @Override
        public String getSubChannel() {
            return CHANNEL;
        }

        @Override
        public void appendPayload(ByteArrayDataOutput out) {
            out.writeUTF(this.playerName);
            out.writeUTF(this.channelName);
            out.writeShort(this.data.length);
            out.write(this.data);
        }
    }

    private static final class ForwardCustomCallback implements MessageCallback {
        private final String subChannel;
        private final Predicate<byte[]> callback;

        private ForwardCustomCallback(String subChannel, Predicate<byte[]> callback) {
            this.subChannel = Objects.requireNonNull(subChannel, "subChannel");
            this.callback = Objects.requireNonNull(callback, "callback");
        }

        @Override
        public String getSubChannel() {
            return this.subChannel;
        }

        @Override
        public boolean acceptResponse(Player receiver, ByteArrayDataInput in) {
            short len = in.readShort();
            byte[] data = new byte[len];
            in.readFully(data);

            return this.callback.test(data);
        }
    }

}