package tc.oc.commons.bukkit.users;

import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import javax.inject.Inject;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import me.anxuiz.settings.Setting;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.api.chat.TranslatableComponent;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventException;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import tc.oc.api.bukkit.friends.OnlineFriends;
import tc.oc.api.bukkit.users.BukkitUserStore;
import tc.oc.api.bukkit.users.OnlinePlayers;
import tc.oc.api.docs.PlayerId;
import tc.oc.api.docs.Server;
import tc.oc.api.docs.Session;
import tc.oc.api.docs.User;
import tc.oc.api.docs.UserId;
import tc.oc.api.docs.virtual.ServerDoc;
import tc.oc.api.message.MessageListener;
import tc.oc.api.message.MessageQueue;
import tc.oc.api.minecraft.MinecraftService;
import tc.oc.api.servers.ServerStore;
import tc.oc.api.sessions.SessionChange;
import tc.oc.commons.bukkit.chat.Audiences;
import tc.oc.commons.bukkit.chat.NameStyle;
import tc.oc.commons.bukkit.chat.PlayerComponent;
import tc.oc.commons.bukkit.format.ServerFormatter;
import tc.oc.commons.bukkit.nick.Identity;
import tc.oc.commons.bukkit.nick.IdentityProvider;
import tc.oc.commons.bukkit.settings.SettingManagerProvider;
import tc.oc.commons.core.chat.Audience;
import tc.oc.commons.core.chat.Component;
import tc.oc.commons.core.plugin.PluginFacet;
import tc.oc.commons.core.util.Lazy;
import tc.oc.minecraft.scheduler.SyncExecutor;

import static com.google.common.base.Preconditions.checkArgument;
import static tc.oc.commons.core.IterableUtils.none;
import static tc.oc.commons.core.util.Nullables.first;
import static tc.oc.commons.core.util.Utils.notEqual;

/**
 * Receives {@link SessionChange} messages from the topic exchange and generates
 * connect/disconnect/change server announcements. ALL announcements are generated
 * from queue messages, even local ones. However, local announcements are synced
 * with their respective Bukkit events, to ensure that they appear ordered correctly
 * in chat, and names are rendered in the correct state.
 *
 * The {@link JoinMessageSetting} affects both local and remote announcements,
 * except that remote events are never displayed to non-friends.
 *
 * If join messages are disabled in the plugin config, no announcements will be
 * made at all by this service.
 *
 * When a player changes their nickname, it will appear exactly as if the old identity
 * disconnected from the network, and the new identity connected, to viewers who can't
 * see through their disguise. If the change is immediate (i.e. nick -i) then both
 * events will appear together in chat on the local server. Non-immediate changes will
 * only show one message in chat, since the other identity is on a different server.
 */
public class JoinMessageAnnouncer implements MessageListener, Listener, PluginFacet {

    private final MessageQueue queue;
    private final OnlineFriends onlineFriends;
    private final IdentityProvider identityProvider;
    private final SettingManagerProvider playerSettings;
    private final JoinMessageConfiguration config;
    private final Audiences audiences;
    private final ServerStore serverStore;
    private final MinecraftService minecraftService;
    private final OnlinePlayers onlinePlayers;
    private final SyncExecutor syncExecutor;
    private final BukkitUserStore userStore;
    private final Setting setting;

    // Events involving the local server are delayed until the actual Bukkit event,
    // so that the player's name is rendered in the correct state. These caches
    // hold the queue message during that delay.
    private final Cache<UserId, SessionChange> pendingJoins = CacheBuilder.newBuilder().expireAfterWrite(10, TimeUnit.SECONDS).build();
    private final Cache<UserId, SessionChange> pendingQuits = CacheBuilder.newBuilder().expireAfterWrite(10, TimeUnit.SECONDS).build();

    @Inject JoinMessageAnnouncer(MessageQueue queue,
                                 OnlineFriends onlineFriends,
                                 IdentityProvider identityProvider,
                                 SettingManagerProvider playerSettings,
                                 JoinMessageConfiguration config,
                                 Audiences audiences,
                                 ServerStore serverStore,
                                 MinecraftService minecraftService,
                                 OnlinePlayers onlinePlayers,
                                 SyncExecutor syncExecutor,
                                 BukkitUserStore userStore) {
        this.queue = queue;
        this.onlineFriends = onlineFriends;
        this.identityProvider = identityProvider;
        this.playerSettings = playerSettings;
        this.config = config;
        this.audiences = audiences;
        this.serverStore = serverStore;
        this.minecraftService = minecraftService;
        this.onlinePlayers = onlinePlayers;
        this.syncExecutor = syncExecutor;
        this.userStore = userStore;
        this.setting = JoinMessageSetting.get();
    }

    @Override
    public boolean isActive() {
        return config.enabled();
    }

    @Override
    public void enable() {
        queue.subscribe(this, syncExecutor);
        queue.bind(SessionChange.class);
    }

    @Override
    public void disable() {
        queue.unsubscribe(this);
    }

    @HandleMessage
    public void onSessionChange(SessionChange change) {
        checkArgument(change.old_session() != null || change.new_session() != null);

        final Server localServer = minecraftService.getLocalServer();
        final boolean localBefore = change.old_session() != null && change.old_session().server_id().equals(localServer._id());
        final boolean localAfter = change.new_session() != null && change.new_session().server_id().equals(localServer._id());

        if(!localBefore && localAfter && onlinePlayers.find(change.new_session().user()) == null) {
            // Joining player is not here yet
            pendingJoins.put(change.new_session().user(), change);
        } else if(localBefore && !localAfter && onlinePlayers.find(change.old_session().user()) != null) {
            // Quitting player hasn't left yet
            pendingQuits.put(change.old_session().user(), change);
        } else {
            announce(change);
        }
    }

    @EventHandler(priority = EventPriority.HIGHEST)
    public void onJoin(PlayerJoinEvent event) {
        event.setJoinMessage(null);
        final User user = userStore.getUser(event.getPlayer());
        final SessionChange change = pendingJoins.getIfPresent(user);
        if(change != null) {
            pendingJoins.invalidate(user);
            announce(change);
        }
    }

    @EventHandler(priority = EventPriority.HIGHEST)
    public void onQuit(PlayerQuitEvent event) throws EventException {
        event.setQuitMessage(null);
        final User user = userStore.getUser(event.getPlayer());
        final SessionChange change = pendingQuits.getIfPresent(user);

        event.yield();

        if(change != null) {
            pendingQuits.invalidate(user);
            announce(change);
        }
    }

    private void announce(SessionChange change) {
        final ChangedSession finished = new ChangedSession(change.old_session());
        final ChangedSession started = new ChangedSession(change.new_session());
        final PlayerId playerId = first(change.old_session(), change.new_session()).user();

        // If neither session is from the local server, just loop through
        // friends of the player, instead of all players online.
        final Stream<? extends Player> viewers =
            finished.isLocal() || started.isLocal() ? onlinePlayers.all().stream()
                                                    : onlineFriends.onlineFriends(playerId);

        // Use lazy messages so we can reuse them for multiple viewers,
        // without generating the ones we don't need. Depending on the
        // situation, we could end up showing one, two, or all three of
        // these messages for a single event.

        final Lazy<BaseComponent> leaveMessage = Lazy.from(() -> {
            final Component c = new Component(ChatColor.YELLOW);
            if(!minecraftService.isLocalServer(finished.server)) {
                c.extra(ServerFormatter.dark.nameWithDatacenter(finished.server)).extra(" ");
            }
            return c.extra(new TranslatableComponent("broadcast.leaveMessage", new PlayerComponent(finished.identity, NameStyle.VERBOSE)));
        });

        final Lazy<BaseComponent> joinMessage = Lazy.from(() -> {
            final Component c = new Component(ChatColor.YELLOW);
            if(!minecraftService.isLocalServer(started.server)) {
                c.extra(ServerFormatter.dark.nameWithDatacenter(started.server)).extra(" ");
            }
            return c.extra(new TranslatableComponent("broadcast.joinMessage", new PlayerComponent(started.identity, NameStyle.VERBOSE)));
        });

        final Lazy<BaseComponent> changeMessage = Lazy.from(() -> new Component(ChatColor.YELLOW)
            .extra(ServerFormatter.dark.nameWithDatacenter(finished.server))
            .extra(" \u00BB ")
            .extra(ServerFormatter.dark.nameWithDatacenter(started.server))
            .extra(" ")
            .extra(new TranslatableComponent("broadcast.changeServerMessage", new PlayerComponent(started.identity, NameStyle.VERBOSE))));

        viewers.forEach(viewerPlayer -> {
            if(viewerPlayer.getName().equals(playerId.username())) return;
            final Viewer viewer = new Viewer(viewerPlayer);

            if(!viewer.sendChangeServer(finished, started, changeMessage)) {
                viewer.sendJoinLeave(leaveMessage, finished, started);
                viewer.sendJoinLeave(joinMessage, started, finished);
            }
        });
    }

    class ChangedSession {
        final Identity identity;
        final Server server;

        private ChangedSession(@Nullable Session session) {
            identity = session == null ? null : identityProvider.createIdentity(session);
            server = session == null ? null : serverStore.byId(session.server_id());
        }

        boolean isLocal() {
            return server != null && minecraftService.isLocalServer(server);
        }

        boolean isVisible() {
            // Can't see a session that does not exist
            if(server == null) return false;

            // Local sessions are always visible
            if(minecraftService.isLocalServer(server)) return true;

            // Private server sessions are never visible
            if(server.visibility() == ServerDoc.Visibility.PRIVATE) return false;

            // Sessions from other networks are (configurably) invisible
            if(!(config.crossNetwork() || minecraftService.getLocalServer().network().equals(server.network()))) return false;

            // Check family/realm visibility filters
            if(!config.families().test(server.family())) return false;
            if(none(server.realms(), config.realms())) return false;

            return true;
        }

        boolean isVisibleTo(CommandSender viewer) {
            // Remote sessions are never visible to non-friends
            return isVisible() && (isLocal() || identity.isFriend(viewer));
        }

        boolean belongsTo(Identity identity, CommandSender viewer) {
            return isVisibleTo(viewer) && this.identity.isSamePerson(identity, viewer);
        }
    }

    class Viewer {
        final Player player;
        final Audience audience;
        final JoinMessageSetting.Options jms;

        private Viewer(Player player) {
            this.player = player;
            this.audience = audiences.get(player);
            this.jms = playerSettings.getManager(player).getValue(setting, JoinMessageSetting.Options.class);
        }

        boolean sendChangeServer(ChangedSession finished, ChangedSession started, Lazy<BaseComponent> message) {
            // If both sessions are visible,
            // and the sessions are on different servers,
            // and the sessions appear to belong to the same person,
            // then show a "change server" message.

            if(finished.isVisibleTo(player) && started.isVisibleTo(player) &&
               notEqual(finished.server, started.server) &&
               finished.identity.isSamePerson(started.identity, player)) {

                if(jms.isAllowed(started.identity.familiarity(player))) {
                    audience.sendMessage(message.get());
                }
                return true;
            }
            return false;
        }

        void sendJoinLeave(Lazy<BaseComponent> message, ChangedSession session, ChangedSession other) {
            // If session A is visible,
            // and session B does not appear to be another session belonging to the same person as session A,
            // and the viewer's setting allows messages about the owner of session A,
            // then inform the viewer about the start/finish of session A.

            if(session.isVisibleTo(player) &&
               !other.belongsTo(session.identity, player) &&
               jms.isAllowed(session.identity.familiarity(player))) {

                audience.sendMessage(message.get());
            }
        }
    }
}
