package tv.twitch.sdk;

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import tv.twitch.CoreAPI;
import tv.twitch.CoreErrorCode;
import tv.twitch.ErrorCode;
import tv.twitch.IModule;
import tv.twitch.ModuleState;
import tv.twitch.ResultContainer;
import tv.twitch.models.UserBadgeModel;
import tv.twitch.twiglib.Logger;
import tv.twitch.util.StringFormatter;
import tv.twitch.chat.ChatAPI;
import tv.twitch.chat.ChatBadge;
import tv.twitch.chat.ChatBadgeImage;
import tv.twitch.chat.ChatBadgeSet;
import tv.twitch.chat.ChatBadgeVersion;
import tv.twitch.chat.ChatBitsConfiguration;
import tv.twitch.chat.ChatChannelInfo;
import tv.twitch.chat.ChatChannelRestrictions;
import tv.twitch.chat.ChatChannelState;
import tv.twitch.chat.ChatEmoticonSet;
import tv.twitch.chat.ChatErrorCode;
import tv.twitch.chat.ChatFirstTimeChatterNotice;
import tv.twitch.chat.ChatLiveMessage;
import tv.twitch.chat.ChatMessageBadge;
import tv.twitch.chat.ChatMessageInfo;
import tv.twitch.chat.ChatRaidNotice;
import tv.twitch.chat.ChatRoomInfo;
import tv.twitch.chat.ChatRoomView;
import tv.twitch.chat.ChatSubscriptionNotice;
import tv.twitch.chat.ChatThreadData;
import tv.twitch.chat.ChatTokenizationOptions;
import tv.twitch.chat.ChatUnraidNotice;
import tv.twitch.chat.ChatUnreadThreadCounts;
import tv.twitch.chat.ChatUserInfo;
import tv.twitch.chat.ChatWhisperMessage;
import tv.twitch.chat.IBitsListener;
import tv.twitch.chat.IBitsStatus;
import tv.twitch.chat.IChannelChatRoomManager;
import tv.twitch.chat.IChannelChatRoomManagerListener;
import tv.twitch.chat.IChatAPIListener;
import tv.twitch.chat.IChatChannelListener;
import tv.twitch.chat.IChatChannelProperties;
import tv.twitch.chat.IChatChannelPropertyListener;
import tv.twitch.chat.IChatRaid;
import tv.twitch.chat.IChatRaidListener;
import tv.twitch.chat.IChatRoomNotifications;
import tv.twitch.chat.IChatRoomNotificationsListener;
import tv.twitch.chat.IChatUserThreadsListener;
import tv.twitch.chat.RoomMentionInfo;

import com.unity3d.player.*;

/**
 * Java layer for SDK chat functionality in StandardChatAPI
 */

/**
 * Adapted by loohill on 05/09/18
 * Last sync: 05/09/18
 * Source: twitch-apps/twitch-android/blob/master/Twitch/src/main/java/tv/twitch/android/sdk/ChatController.java
 * Changes:
 *** Replace logger to ours;
 *** Uses UnityPlayer.currentActivity.getApplication() as Context for SingletonHolder instead of TwitchApplication.get();
 *** Removed all codes related to ChatRoom(We are not supporting);
 *** Turned off tokenizationOptions.emoticons (requires first-party client id token);
 */
public class ChatController {

    private final static String TAG = "Twitch_ChatController";
    public static final String BITS_BADGE_SET = "bits";
    public static final int GLOBAL_BADGE_CHANNEL = -1;
    public static final int ANONYMOUS_USERID = 0;
    public static final int CHAT_EMOTESET_ID_STANDARD = 0;
    public static final int CHAT_EMOTESET_ID_TURBO_GLITCH = 33;
    public static final int CHAT_EMOTESET_ID_TURBO_MONKEY = 42;

    public static final String CHANNEL_NOTICE_HOST_RATE_EXCEEDED = "bad_host_rate_exceeded";

    /**
     * The possible states the ChatController can be in.
     */
    public enum ChatState {
        Uninitialized,  //!< The component is not yet initialized.
        Initializing,   //!< The component is initializing.
        Initialized,    //!< The component is initialized.
        ShuttingDown,    //!< The component is shutting down.
    }

    /**
     * The possible states a chat channel can be in.
     */
    public enum ChannelState {
        Disconnected,
        Connecting,
        Connected,
        Disconnecting
    }

    /**
     * The listener interface for events from the ChatController.
     */
    public interface IControllerListener {
        void onChatInitializationComplete(ErrorCode result);
        void onChatShutdownComplete(ErrorCode result);
        void onChatStateChanged(ChatState state, ErrorCode result);
        void onChatUserEmoticonSetUpdated(@Nullable ChatEmoticonSet[] sets);
    }

    public interface IChannelListener {
        void onChannelStateChanged(int channelId, ChannelState state, ErrorCode ec);
        void onChannelInfoChanged(int channelId, ChatChannelInfo channelInfo);
        void onChannelLocalUserChanged(int channelId, ChatUserInfo userInfo);
        void onChannelUserChange(int channelId, List<ChatUserInfo> users);
        void onChannelMessageReceived(int channelId, ChatLiveMessage[] messageList);
        void onChannelUserMessagesCleared(int channelId, int userId);
        void onChannelMessagesCleared(int channelId);
        void onChannelHostTargetChanged(int channelId, String targetChannelName, int numViewers);
        void onChannelNoticeReceived(int channelId, String noticeId, HashMap<String, String> params);
        void onChannelChatMessageSendError(int channelId, ErrorCode ec);
        void onChannelRaidNoticeReceived(int channelId, ChatRaidNotice chatRaidNotice);
        void onChannelUnraidNoticeReceived(int channelId, ChatUnraidNotice chatUnraidNotice);
        void onChannelFirstTimeChatterNoticeReceived(int channelId, int userId, ChatFirstTimeChatterNotice chatFirstTimeChatterNotice);
        void onChannelSubscriptionNoticeReceived(int channelId, int userId, ChatSubscriptionNotice chatSubscriptionNotice);
    }

    private Map<Integer, Set<String>> mConnectionContexts = new HashMap<>(); // channel id to set of context strings

    private Set<IControllerListener> mControllerListeners = Collections.newSetFromMap(new ConcurrentHashMap<IControllerListener, Boolean>());
    private Set<IChannelListener> mChannelListeners = Collections.newSetFromMap(new ConcurrentHashMap<IChannelListener, Boolean>());
    private Set<IChatUserThreadsListener> mThreadListeners = Collections.newSetFromMap(new ConcurrentHashMap<IChatUserThreadsListener, Boolean>());

    private int mUserId = 0;

    private ChatAPI mChatAPI = null;
    private ChatState mChatState = ChatState.Uninitialized;

    final private HashMap<Integer, ChatChannelListener> mChannels = new HashMap<>(); // channel id  -> chat channel listener
    @Nullable private ChatEmoticonSet[] mEmoticonSets = null;
    private ConcurrentHashMap<Integer, HashMap<String, ChatBadgeImage>> mBadges = new ConcurrentHashMap<>(); // <channelId -> <set/version -> url>>

    // This is a set of channel ids that we are currently requesting a badge set for
    private Set<Integer> mRequestingBadgeSet = new HashSet<>();

    public int getUserId() {return mUserId; }
    public ChatAPI getChatApi() {
        return mChatAPI;
    }

    protected IChatAPIListener mChatAPIListener = new IChatAPIListener() {

        @Override
        public void chatUserEmoticonSetsChanged(int userId, ChatEmoticonSet[] emoticonSets) {
            try {
                mEmoticonSets = emoticonSets;
                for (IControllerListener listener : mControllerListeners) {
                    listener.onChatUserEmoticonSetUpdated(mEmoticonSets);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        @Override
        public void moduleStateChanged(IModule source, ModuleState state, ErrorCode result) {
            if (result.failed()) {
                reportError(String.format("Error in module state changed chat sdk: %s", SDKLibrary.getInstance().errorToString(result)));
            }

            if (state == ModuleState.Initialized) {
                mChatAPI.setMessageFlushInterval(SDKServicesController.UPDATE_INTERVAL);

                _setChatState(ChatState.Initialized, result);

                try {
                    for (IControllerListener listener : mControllerListeners) {
                        listener.onChatInitializationComplete(result);
                    }
                } catch (Exception e) {
                    reportError(e.toString());
                }

            } else if (state == ModuleState.Uninitialized) {
                if (result.succeeded()) {
                    _setChatState(ChatState.Uninitialized, result);
                }

                try {
                    for (IControllerListener listener : mControllerListeners) {
                        listener.onChatShutdownComplete(result);
                    }
                } catch (Exception e) {
                    reportError(e.toString());
                }
            }
        }
    };

    private class ChatChannelListener implements IChatChannelListener {
        private int mChannelId = 0;
        private ChannelState m_ChannelState = ChannelState.Disconnected;
        private boolean m_DisconnectOutstanding = false; // Whether or not waiting for a callback for a disconnect command.

        public ChatChannelListener(int channelId) {
            mChannelId = channelId;
        }

        public ChannelState getChannelState() {
            return m_ChannelState;
        }

        public boolean getDisconnectOutstanding() {
            return m_DisconnectOutstanding;
        }

        public ErrorCode connect(int userId) {
            ErrorCode ec;

            // connect to the channel
            ec = mChatAPI.connect(userId, mChannelId, this);

            if (ec.failed()) {
                if (ec == ChatErrorCode.TTV_EC_CHAT_LEAVING_CHANNEL) {
                    Logger.d(TAG, "trying to disconnect while already leaving channel");
                } else {
                    String err = SDKLibrary.getInstance().errorToString(ec);
                    reportError(String.format("Error connecting: %s", err));
                }
            }

            return ec;
        }

        public ErrorCode disconnect(int userId) {
            ErrorCode ec;
            switch (m_ChannelState) {
                case Connected:
                case Connecting: {
                    // kick off an async disconnect
                    ec = mChatAPI.disconnect(userId, mChannelId);

                    if (ec.succeeded()) {
                        // Keep track of this request since we want to queue up connect messages until this completes
                        m_DisconnectOutstanding = true;
                    } else {
                        String err = SDKLibrary.getInstance().errorToString(ec);
                        reportError(String.format("Error disconnecting: %s", err));
                    }
                    break;
                }
                default: {
                    ec = CoreErrorCode.TTV_EC_SUCCESS;
                }
            }

            return ec;
        }

        protected void setChannelState(ChannelState state, ErrorCode result) {
            if (state == m_ChannelState) {
                return;
            }

            m_ChannelState = state;

            Logger.d(TAG, "Chat channel changed state: " + mChannelId + " - " + state.toString());

            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelStateChanged(mChannelId, state, result);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        public void clearUserMessages(int userId) {
            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelUserMessagesCleared(mChannelId, userId);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        public void clearChannelMessages() {
            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelMessagesCleared(mChannelId);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        public boolean sendChatMessage(String message) {
            if (m_ChannelState != ChannelState.Connected) {
                return false;
            }

            ErrorCode ret = mChatAPI.sendMessage(mUserId, mChannelId, message);
            if (ret.failed()) {
                String err = SDKLibrary.getInstance().errorToString(ret);
                reportError(String.format("Error sending chat message: %s", err));

                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelChatMessageSendError(mChannelId, ret);
                }

                return false;
            }

            return true;
        }

        @Override
        public void chatChannelStateChanged(int userId, final int channelId, ChatChannelState state, ErrorCode result) {
            switch (state) {
                case Disconnected: {
                    m_DisconnectOutstanding = false;
                    removeChannel(channelId);
                    setChannelState(ChannelState.Disconnected, result);

                    // remove channel badges from local cache on disconnect
                    if (channelId != GLOBAL_BADGE_CHANNEL) {
                        mBadges.remove(channelId);
                    }
                    break;
                }
                case Connecting: {
                    setChannelState(ChannelState.Connecting, result);
                    break;
                }
                case Connected: {
                    setChannelState(ChannelState.Connected, result);
                    fetchChannelBadges(channelId);
                    break;
                }
                case Disconnecting: {
                    setChannelState(ChannelState.Disconnecting, result);
                    break;
                }
            }
        }

        @Override
        public void chatChannelInfoChanged(int userId, int channelId, ChatChannelInfo channelInfo) {
            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelInfoChanged(mChannelId, channelInfo);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        @Override
        public void chatChannelRestrictionsChanged(int userId, int channelId, ChatChannelRestrictions restrictions) {
            // No Op - Opened a jira to implement, we're fine to no op for now AND-3120
        }

        @Override
        public void chatChannelLocalUserChanged(int userId, int channelId, ChatUserInfo userInfo) {
            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelLocalUserChanged(mChannelId, userInfo);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        @Override
        public void chatChannelMessagesReceived(int userId, int channelId, ChatLiveMessage[] messageList) {
            for (ChatLiveMessage message : messageList) {
                internationalizeChatMessage(message.messageInfo);
            }

            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelMessageReceived(mChannelId, messageList);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        @Override
        public void chatChannelSubscriptionNoticeReceived(int userId, int channelId, ChatSubscriptionNotice notice) {
            try {
                for (IChannelListener listener: mChannelListeners) {
                    listener.onChannelSubscriptionNoticeReceived(channelId, userId, notice);
                }
            } catch (Exception x) {
                reportError(x.toString());
            }
        }

        @Override
        public void chatChannelFirstTimeChatterNoticeReceived(int userId, int channelId, ChatFirstTimeChatterNotice notice) {
            try {
                for (IChannelListener listener: mChannelListeners) {
                    listener.onChannelFirstTimeChatterNoticeReceived(channelId, userId, notice);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        @Override
        public void chatChannelRaidNoticeReceived(int userId, int channelId, ChatRaidNotice notice) {
            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelRaidNoticeReceived(channelId, notice);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        @Override
        public void chatChannelUnraidNoticeReceived(int userId, int channelId, ChatUnraidNotice notice) {
            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelUnraidNoticeReceived(channelId, notice);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        @Override
        public void chatChannelMessagesCleared(int userId, int channelId) {
            if (mChatState != ChatState.Initialized) {
                return;
            }

            if (!mChannels.containsKey(channelId)) {
                Logger.d(TAG, "Not in channel: " + channelId);
                return;
            }

            ChatChannelListener channel = getChannel(channelId);
            channel.clearChannelMessages();
        }

        @Override
        public void chatChannelUserMessagesCleared(int userId, int channelId, int clearUserId) {
            if (mChatState != ChatState.Initialized) {
                return;
            }

            if (!mChannels.containsKey(channelId)) {
                Logger.d(TAG, "Not in channel: " + channelId);
                return;
            }

            if (userId <= 0) {
                return;
            }

            ChatChannelListener channel = getChannel(channelId);
            channel.clearUserMessages(clearUserId);
        }

        @Override
        public void chatChannelHostTargetChanged(int userId, int channelId, String targetChannelName, int numViewers) {
            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelHostTargetChanged(mChannelId, targetChannelName, numViewers);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }

        @Override
        public void chatChannelNoticeReceived(int userId, int channelId, String noticeId, HashMap<String, String> params) {
            try {
                for (IChannelListener listener : mChannelListeners) {
                    listener.onChannelNoticeReceived(mChannelId, noticeId, params);
                }
            } catch (Exception e) {
                reportError(e.toString());
            }
        }
    }

    private IChatUserThreadsListener mChatUserThreadsListener = new IChatUserThreadsListener() {

        @Override
        public void chatThreadRealtimeMessageReceived(int userId, String threadId, ChatWhisperMessage message) {
            if (userId == mUserId) {
                internationalizeChatMessage(message.messageInfo);
                for (IChatUserThreadsListener listener : mThreadListeners) {
                    listener.chatThreadRealtimeMessageReceived(userId, threadId, message);
                }
            }
        }

        @Override
        public void chatThreadParticipantsUpdated(int userId, String threadId, ChatUserInfo[] participants) {
            if (userId == mUserId) {
                if (participants != null) {
                    for (ChatUserInfo participant : participants) {
                        internationalizeChatUserInfo(participant);
                    }
                }
                for (IChatUserThreadsListener listener : mThreadListeners) {
                    listener.chatThreadParticipantsUpdated(userId, threadId, participants);
                }
            }
        }

        @Override
        public void chatThreadMutedStatusChanged(int userId, String threadId, boolean muted) {
            if (userId == mUserId) {
                for (IChatUserThreadsListener listener : mThreadListeners) {
                    listener.chatThreadMutedStatusChanged(userId, threadId, muted);
                }
            }
        }

        @Override
        public void chatThreadUnreadMessageWindowChanged(int userId, String threadId, int lastMessageId, int lastReadMessageId) {
            for (IChatUserThreadsListener listener : mThreadListeners) {
                listener.chatThreadUnreadMessageWindowChanged(userId, threadId, lastMessageId, lastReadMessageId);
            }
        }

        @Override
        public void chatThreadRemoved(int userId, String threadId) {
            if (userId == mUserId) {
                for (IChatUserThreadsListener listener : mThreadListeners) {
                    listener.chatThreadRemoved(userId, threadId);
                }
            }

        }

        @Override
        public void chatThreadGlobalUnreadCountsChanged(int userId, ChatUnreadThreadCounts counts) {
            if (userId == mUserId) {
                for (IChatUserThreadsListener listener : mThreadListeners) {
                    listener.chatThreadGlobalUnreadCountsChanged(userId, counts);
                }
            }
        }
    };

    public void reset() {
        mChatAPI = new ChatAPI();
        mChatState = ChatState.Uninitialized;
        mUserId = 0;
        mConnectionContexts.clear();
        mControllerListeners.clear();
        mChannelListeners.clear();
        mThreadListeners.clear();
        mChannels.clear();
        mEmoticonSets = null;
        mBadges.clear();
        mRequestingBadgeSet.clear();
    }

    public boolean initialize(CoreAPI core) {
        if (mChatState != ChatState.Uninitialized) {
            return false;
        }

        _setChatState(ChatState.Initializing, CoreErrorCode.TTV_EC_SUCCESS);

        ErrorCode ret;
        if (!SDKLibrary.getInstance().isInitialized()) {
            ret = CoreErrorCode.TTV_EC_NOT_INITIALIZED;

            _setChatState(ChatState.Uninitialized, ret);
            reportError(String.format("Error initializing Twitch sdk: %s", SDKLibrary.getInstance().errorToString(ret)));
            return false;
        }

        // initialize chat
        ChatTokenizationOptions tokenizationOptions = new ChatTokenizationOptions();
        tokenizationOptions.emoticons = false;
        tokenizationOptions.mentions = true;
        tokenizationOptions.urls = true;
        tokenizationOptions.bits = true;

        // kick off the async init
        mChatAPI.setCoreApi(core);
        mChatAPI.setTokenizationOptions(tokenizationOptions);
        mChatAPI.setListener(mChatAPIListener);
        ret = mChatAPI.initialize(new IModule.InitializeCallback() {
            @Override
            public void invoke(ErrorCode ec) {

            }
        });
        if (ret.failed()) {
            _setChatState(ChatState.Uninitialized, ret);
            reportError(String.format("Error initializing Twitch chat: %s", SDKLibrary.getInstance().errorToString(ret)));
            return false;
        } else {
            _setChatState(ChatState.Initialized, CoreErrorCode.TTV_EC_SUCCESS);
            return true;
        }
    }

    public void setUserId(int userId) {
        if (mChatState != ChatState.Initialized) {
            return;
        }

        int previousUser = mUserId;

        if (previousUser > 0 && previousUser != userId) {
            // if we had a previous user but it does not equal new user...
            mChatAPI.setUserThreadsListener(previousUser, null);
        }

        mUserId = userId;

        if (mUserId > 0) {
            // if we have a real new user...
            mChatAPI.setUserThreadsListener(mUserId, mChatUserThreadsListener);
        }
    }

    public void update() {
        if (mChatState == ChatState.Uninitialized) {
            Logger.d(TAG, "chat controller uninitialized in update call");
            return;
        }

        ErrorCode ret = mChatAPI.update();
        if (ret.failed()) {
            reportError(String.format("Error flushing chat events: %s", SDKLibrary.getInstance().errorToString(ret)));
        }
    }

    public void updateGlobalBadges() {
        if (!mBadges.containsKey(GLOBAL_BADGE_CHANNEL)) {
            mChatAPI.fetchGlobalBadges(new ChatAPI.FetchBadgesCallback() {
                @Override
                public void invoke(ErrorCode ec, ChatBadgeSet badgeSet) {
                    if (ec.succeeded()) {
                        updateChannelBadges(badgeSet, GLOBAL_BADGE_CHANNEL);
                    }
                }
            });
        }
    }

    /**
     * Ensures the controller is fully shutdown before returning.  This may fire callbacks to listeners during the shutdown.
     */
    public void forceSyncShutdown(CoreAPI core) {
        if (this.getControllerState() != ChatState.Uninitialized) {
            this._shutdown();

            // wait for the shutdown to finish
            if (this.getControllerState() == ChatState.ShuttingDown) {
                while (this.getControllerState() != ChatState.Uninitialized) {
                    try {
                        Thread.sleep(SDKServicesController.SHUTDOWN_UPDATE_INTERVAL);
                        core.update();
                        update();
                    } catch (InterruptedException ignored) {
                    }
                }
            }
        }
    }


    private void fireChannelStateChanged(final int channelId, final ChannelState state, final ErrorCode ec) {
        try {
            for (IChannelListener listener : mChannelListeners) {
                listener.onChannelStateChanged(channelId, state, ec);
            }
        } catch (Exception e) {
            reportError(e.toString());
        }
    }

    public void addControllerListener(final IControllerListener listener) {
        if (listener != null) {
            mControllerListeners.add(listener);
        }
    }

    public void removeControllerListener(final IControllerListener listener) {
        if (listener != null) {
            mControllerListeners.remove(listener);
        }
    }

    public void addChannelListener(final IChannelListener listener) {
        if (listener != null) {
            mChannelListeners.add(listener);
        }
    }

    public void removeChannelListener(final IChannelListener listener) {
        if (listener != null) {
            mChannelListeners.remove(listener);
        }
    }

    public void addThreadListener(final IChatUserThreadsListener listener) {
        if (listener != null) {
            mThreadListeners.add(listener);
        }
    }

    public void removeThreadListener(final IChatUserThreadsListener listener) {
        if (listener != null) {
            mThreadListeners.remove(listener);
        }
    }

    public String generateThread(int otherUserId) {
        ResultContainer<String> result = new ResultContainer<>();
        mChatAPI.generateThreadId(mUserId, otherUserId, result);
        return result.result;
    }

    public void requestThreadPage(final int offset, final int count, final ChatAPI.FetchThreadDataPageCallback callback) {
        if (mUserId <= 0) {
            return;
        }

        ErrorCode ec = mChatAPI.fetchThreadDataPage(mUserId, offset, count, new ChatAPI.FetchThreadDataPageCallback() {
            @Override
            public void invoke(ErrorCode ec, ChatThreadData[] threadsData, int total) {
                if (threadsData != null && callback != null) {
                    for (ChatThreadData threadData : threadsData) {
                        internationalizeChatThread(threadData);
                    }
                    callback.invoke(ec, threadsData, total);
                }
            }
        });

        if (ec.failed()) {
            reportError(String.format("error fetching thread page: %s", SDKLibrary.getInstance().errorToString(ec)));
        }
    }

    public void requestThreadMessages(final String id, final int offset, final int count, final ChatAPI.FetchThreadMessagesCallback callback) {

        if (mUserId <= 0) {
            return;
        }
        Logger.d(TAG, "THREAD REQUEST MESSAGES: " + id);

        ErrorCode ec = mChatAPI.fetchThreadMessages(mUserId, id, offset, count, new ChatAPI.FetchThreadMessagesCallback() {
            @Override
            public void invoke(ErrorCode ec, ChatWhisperMessage[] messages) {
                if (messages != null && callback != null) {
                    for (ChatWhisperMessage message : messages) {
                        internationalizeChatMessage(message.messageInfo);
                    }
                    callback.invoke(ec, messages);
                }
            }
        });
        if (ec.failed()) {
            reportError(String.format("error requesting thread messages: %s", SDKLibrary.getInstance().errorToString(ec)));
        }
    }

    public void requestThread(final String threadId, final ChatAPI.FetchThreadDataCallback callback) {
        if (mUserId <= 0) {
            return;
        }
        Logger.d(TAG, "THREAD REQUEST THREAD: " + threadId);

        ErrorCode ec = mChatAPI.fetchUserThreadData(mUserId, threadId, new ChatAPI.FetchThreadDataCallback() {
            @Override
            public void invoke(ErrorCode ec, ChatThreadData threadData) {
                if (callback != null) {
                    if (threadData != null) {
                        internationalizeChatThread(threadData);
                    }
                    callback.invoke(ec, threadData);
                }
            }
        });
        if (ec.failed()) {
            reportError(String.format("error requesting thread: %s", SDKLibrary.getInstance().errorToString(ec)));
        }
    }

    public void requestSetThreadRead(final String threadId, final int lastReadMessageId, final ChatAPI.SetLastMessageReadIdCallback callback) {
        if (mUserId <= 0) {
            return;
        }
        Logger.d(TAG, "THREAD REQUEST SET READ: " + threadId);

        ErrorCode ec = mChatAPI.setLastMessageReadId(mUserId, threadId, lastReadMessageId, callback);
        if (ec.failed()) {
            reportError(String.format("error setting last message read id: %s", SDKLibrary.getInstance().errorToString(ec)));
        }
    }

    public void requestSetThreadMuted(final String threadId, final boolean muted, final ChatAPI.SetThreadMutedCallback callback) {

        if (mUserId <= 0) {
            return;
        }

        ErrorCode ec = mChatAPI.setThreadMuted(mUserId, threadId, muted, callback);
        if (ec.failed()) {
            reportError(String.format("error setting thread muted: %s", SDKLibrary.getInstance().errorToString(ec)));
        }
    }

    public void requestSetThreadArchived(final String threadId, final boolean archived, final ChatAPI.SetThreadArchivedCallback callback) {
        if (mUserId <= 0) {
            return;
        }

        ErrorCode ec = mChatAPI.setThreadArchived(mUserId, threadId, archived, callback);
        if (ec.failed()) {
            reportError(String.format("error setting thread archived: %s", SDKLibrary.getInstance().errorToString(ec)));
        }
    }

    public void requestThreadUnreadCount(final ChatAPI.FetchThreadUnreadCountsCallback callback) {
        if (mUserId <= 0) {
            return;
        }

        // sdk caches count
        Logger.d(TAG, "THREAD UNREAD COUNT FETCH");
        ErrorCode ec = mChatAPI.fetchUnreadCounts(mUserId, callback);
        if (ec.failed() && ec != CoreErrorCode.TTV_EC_REQUEST_PENDING) {
            reportError(String.format("error fetching unread count: %s", SDKLibrary.getInstance().errorToString(ec)));
        }
    }

    public ChatState getControllerState() {
        return mChatState;
    }

    public ChannelState getChannelState(int channelId) {
        ChatChannelListener channel = getChannel(channelId);
        if (channel == null) {
            return ChannelState.Disconnected;
        }

        return channel.getChannelState();
    }

    @Nullable
    public ChatEmoticonSet[] getEmoticonSets() {
        return mEmoticonSets;
    }

    @Nullable
    public ChatBadgeImage getChatBadgeImage(int channelId, @Nullable ChatMessageBadge badge) {
        if (badge == null) {
            return null;
        }
        return getChatBadgeImage(channelId, badge.name, badge.version);
    }

    @Nullable
    public ChatBadgeImage getChatBadgeImage(int channelId, @Nullable UserBadgeModel userBadge) {
        if (userBadge == null) {
            return null;
        }
        return getChatBadgeImage(channelId, userBadge.id, userBadge.version);
    }

    @Nullable
    public ChatBadgeImage getChatBadgeImage(int channelId, @Nullable String badgeName, @Nullable String badgeVersion) {
        if (badgeName == null || badgeVersion == null || mBadges == null) {
            return null;
        }

        if (mBadges.containsKey(channelId)) {
            ChatBadgeImage chatBadgeImage = mBadges.get(channelId).get(badgeName + "/" + badgeVersion);
            if (chatBadgeImage != null) {
                return chatBadgeImage;
            }
        }

        // Not found using channelId so try Global badge set if available
        HashMap<String, ChatBadgeImage> globalBadges = mBadges.get(GLOBAL_BADGE_CHANNEL);
        if (globalBadges != null && globalBadges.containsKey(badgeName + "/" + badgeVersion)) {
            return globalBadges.get(badgeName + "/" + badgeVersion);
        }

        return null;
    }

    public boolean hasFetchedOrIsFetchingBadgesForChannel(@NonNull Integer channelId) {
        return mBadges.containsKey(channelId) || mRequestingBadgeSet.contains(channelId);
    }

    public void fetchChannelBadges(final int channelId) {
        // Check if we have channel id in our local badge cache. If we do, we don't need to request
        // badges again.
        if (!mBadges.containsKey(channelId)) {
            mRequestingBadgeSet.add(channelId);
            mChatAPI.fetchChannelBadges(channelId, new ChatAPI.FetchBadgesCallback() {
                @Override
                public void invoke(ErrorCode ec, ChatBadgeSet badgeSet) {
                    if (badgeSet != null) {
                        updateChannelBadges(badgeSet, channelId);
                    }
                    mRequestingBadgeSet.remove(channelId);
                }
            });
        }
    }

    private void updateChannelBadges(ChatBadgeSet sets, int channelId) {
        if (sets == null || mBadges == null) {
            return;
        }

        float density = UnityPlayer.currentActivity.getApplication().getResources().getDisplayMetrics().density;
        mBadges.remove(channelId);

        for (ChatBadge badge : sets.badges.values()) {
            String setName = badge.name;
            if (setName == null) {
                return;
            }

            for (ChatBadgeVersion version : badge.versions.values()) {
                String versionName = version.name;
                if (versionName == null || version.images == null || version.images.length < 1) {
                    continue;
                }

                // choose the closest scale
                ChatBadgeImage bestImage = null;
                float bestDiff = Float.MAX_VALUE;
                for (int i = 0; i < version.images.length; i++) {
                    if (version.images[i] != null) {
                        float curDiff = Math.abs(version.images[i].scale - density);
                        if (bestDiff > curDiff) {
                            bestImage = version.images[i];
                            bestDiff = curDiff;
                        }
                    }
                }

                if (bestImage != null) {
                    if (mBadges.get(channelId) == null) {
                        mBadges.put(channelId, new HashMap<String, ChatBadgeImage>());
                    }
                    mBadges.get(channelId).put(setName + "/" + versionName, bestImage);
                }
            }
        }
    }

    public void connect(final int userId, final int channelId, final String context) {
        if (channelId > 0 && !getDisconnectOutstanding(channelId)) {
            addConnectionContext(channelId, context);
            _connect(userId, channelId);
        }
    }

    public void disconnect(final int userId, final int channelId, final String context) {

        if (mConnectionContexts != null && mConnectionContexts.containsKey(channelId)) {
            mConnectionContexts.get(channelId).remove(context);
            if (!mConnectionContexts.get(channelId).isEmpty()) {
                return;
            }
        }

        _disconnect(userId, channelId);
    }

    private void addConnectionContext(final int channelId, final String context) {
        if (mConnectionContexts != null) {
            if (!mConnectionContexts.containsKey(channelId)) {
                mConnectionContexts.put(channelId, new HashSet<String>());
            }

            mConnectionContexts.get(channelId).add(context);
        }
    }

    public boolean sendMessage(final int channelId, final String message) {
        if (mChatState != ChatState.Initialized) {
            return false;
        }

        if (!mChannels.containsKey(channelId)) {
            Logger.d(TAG, "Not in channel: " + channelId);
            return false;
        }

        ChatChannelListener channel = getChannel(channelId);
        return channel.sendChatMessage(message);
    }

    public void sendWhisper(final int userId, final String message, ResultContainer<ChatWhisperMessage> placeholderMessage, final ChatAPI.SendMessageCallback callback) {
        if (userId <= 0) {
            return;
        }

        if (mChatState != ChatState.Initialized || mUserId <= 0) {
            return;
        }

        ErrorCode ec = mChatAPI.sendMessageToUser(mUserId, userId, message, placeholderMessage, callback);

        if (placeholderMessage.result != null) {
            internationalizeChatMessage(placeholderMessage.result.messageInfo);
        }
    }

    public boolean blockUser(final int blockUserId, final String reason, final boolean fromWhisper, final ChatAPI.BlockChangeCallback callback) {
        if (mChatState != ChatState.Initialized) {
            return false;
        }

        ErrorCode ret = mChatAPI.blockUser(mUserId, blockUserId, reason, fromWhisper, callback);
        if (ret.failed()) {
            reportError(String.format("Error blocking user: %s", SDKLibrary.getInstance().errorToString(ret)));
            return false;
        }
        return true;
    }

    public boolean unblockUser(final int unblockUserId, final ChatAPI.BlockChangeCallback callback) {
        if (mChatState != ChatState.Initialized) {
            return false;
        }

        ErrorCode ret = mChatAPI.unblockUser(mUserId, unblockUserId, callback);
        if (ret.failed()) {
            reportError(String.format("Error unblocking user: %s", SDKLibrary.getInstance().errorToString(ret)));
            return false;
        }
        return true;
    }

    public boolean isUserBlocked(final int userId) {
        ResultContainer<Boolean> result = new ResultContainer<>();
        ErrorCode ret = mChatAPI.getUserBlocked(mUserId, userId, result);

        if (ret.failed()) {
            reportError(String.format("Error checking if user blocked: %s", SDKLibrary.getInstance().errorToString(ret)));
            return false;
        }

        return result.result;
    }

    public void optInToBroadcasterLanguageChat(final int channelId, final String language) {
        if (mChatState != ChatState.Initialized) {
            return;
        }

        if (!mChannels.containsKey(channelId)) {
            Logger.d(TAG, "Not in channel: " + channelId);
            return;
        }

        mChatAPI.optInToBroadcasterLanguageChat(mUserId, channelId, language);
    }

    protected ChatChannelListener getChannel(int channelId) {
        synchronized (mChannels) {
            return mChannels.get(channelId);
        }
    }

    protected ChatChannelListener putChannel(int channelId, ChatChannelListener channel) {
        synchronized (mChannels) {
            return mChannels.put(channelId, channel);
        }
    }

    protected ChatChannelListener removeChannel(int channelId) {
        synchronized (mChannels) {
            return mChannels.remove(channelId);
        }
    }

    private boolean _connect(int userId, int channelId) {
        Logger.d(TAG, "_connect");
        if (mChatState != ChatState.Initialized) {
            fireChannelStateChanged(channelId, ChannelState.Disconnected, CoreErrorCode.TTV_EC_NOT_INITIALIZED);
            reportError("Chat not initialized on connect");
            return false;
        }

        if (channelId <= 0) {
            fireChannelStateChanged(channelId, ChannelState.Disconnected, CoreErrorCode.TTV_EC_INVALID_CHANNEL_ID);
            reportError("Invalid channel");
            return false;
        }

        ChatChannelListener channel;

        if (mChannels.containsKey(channelId)) {
            channel = getChannel(channelId);
        } else {
            channel = new ChatChannelListener(channelId);
            putChannel(channelId, channel);
        }

        ErrorCode ec = channel.connect(userId);

        if (ec.failed()) {
            reportError("Chat connect request failed synchronously: " + ec.toString());
            fireChannelStateChanged(channelId, ChannelState.Disconnected, ec);
            return false;
        }

        return ec.succeeded();
    }

    private boolean _disconnect(int userId, int channelId) {
        if (mChatState != ChatState.Initialized) {
            fireChannelStateChanged(channelId, ChannelState.Disconnected, CoreErrorCode.TTV_EC_NOT_INITIALIZED);
            Logger.d(TAG, "Chat not initialized on disconnect"); // harmless
            return false;
        }

        if (!mChannels.containsKey(channelId)) {
            Logger.d(TAG, "Not in channel");
            fireChannelStateChanged(channelId, ChannelState.Disconnected, ChatErrorCode.TTV_EC_CHAT_NOT_IN_CHANNEL);
            return false;
        }

        ChatChannelListener channel = getChannel(channelId);

        ErrorCode ec = channel.disconnect(userId);
        if (ec.failed()) {
            fireChannelStateChanged(channelId, channel.getChannelState(), ec);
        }

        return ec.succeeded();
    }

    protected boolean _shutdown() {
        if (mChatState != ChatState.Initialized) {
            Logger.d(TAG, "did not shut down chat controller because not initialized");
            return false;
        }

        // Shutdown asynchronously
        ErrorCode ret = mChatAPI.shutdown(new IModule.ShutdownCallback() {
            @Override
            public void invoke(ErrorCode ec) {

            }
        });
        if (ret.failed()) {
            reportError(String.format("Error shutting down chat: %s", SDKLibrary.getInstance().errorToString(ret)));
            return false;
        }

        _setChatState(ChatState.ShuttingDown, CoreErrorCode.TTV_EC_SUCCESS);

        return true;
    }

    public boolean getDisconnectOutstanding(int channelId) {
        ChatChannelListener channel = getChannel(channelId);
        if (channel == null) {
            return false;
        }

        return channel.getDisconnectOutstanding();
    }

    protected void _setChatState(ChatState state, ErrorCode result) {
        if (state == mChatState) {
            return;
        }

        mChatState = state;

        Logger.d(TAG, "ChatController changed state: " + state.toString());

        try {
            for (IControllerListener listener : mControllerListeners) {
                listener.onChatStateChanged(state, result);
            }
        } catch (Exception e) {
            reportError(e.toString());
        }
    }

    protected void reportError(String err) {
        System.out.println(err);
        Logger.e(TAG, err);
    }

    private void internationalizeChatThread(ChatThreadData thread) {
        for (ChatUserInfo info : thread.participants) {
            internationalizeChatUserInfo(info);
        }
        if (thread.lastMessage != null) {
            internationalizeChatMessage(thread.lastMessage.messageInfo);
        }
    }

    private void internationalizeChatMessage(ChatMessageInfo message) {
        if (message != null) {
            message.displayName = StringFormatter.internationalizedDisplayName(message.displayName, message.userName);
        }
    }

    private void internationalizeChatUserInfo(ChatUserInfo info) {
        if (info != null) {
            info.displayName = StringFormatter.internationalizedDisplayName(info.displayName, info.userName);
        }
    }

    public void tokenizeServerMessage(String message, ChatTokenizationOptions tokenizationOptions, String emoticonRanges, ResultContainer<ChatMessageInfo> result) {
        // proxy to avoid calling static SDK methods directly - possible SDK is not set up yet
        ChatAPI.tokenizeServerMessage(message, tokenizationOptions, emoticonRanges, new String[0], result);
    }

    public void tokenizeServerMessage(String message, ChatTokenizationOptions tokenizationOptions, String emoticonRanges, ChatBitsConfiguration bitsConfiguration, ResultContainer<ChatMessageInfo> result) {
        // proxy to avoid calling static SDK methods directly - possible SDK is not set up yet
        ChatAPI.tokenizeServerMessage(message, tokenizationOptions, emoticonRanges, bitsConfiguration, new String[0], result);
    }

    @Nullable
    public IChatRaid registerRaidsListener(int channelId, @NonNull IChatRaidListener listener) {
        ResultContainer<IChatRaid> result = new ResultContainer<>();
        ErrorCode ret = mChatAPI.createChatRaid(mUserId, channelId, listener, result);

        if (ret.failed()) {
            reportError(String.format("Error creating ChatRaid object: %s", SDKLibrary.getInstance().errorToString(ret)));
            return null;
        }

        return result.result;
    }

    @NonNull
    public ErrorCode unregisterRaidsListener(IChatRaid chatRaid) {
        return mChatAPI.disposeChatRaid(chatRaid);
    }

    @Nullable
    public IChatChannelProperties registerChannelPropertiesListener(int channelId, @NonNull IChatChannelPropertyListener listener) {
        ResultContainer<IChatChannelProperties> result = new ResultContainer<>();
        ErrorCode ret = mChatAPI.createChatChannelProperties(mUserId, channelId, listener, result);

        if (ret.failed()) {
            reportError(String.format("Error creating ChatChannelProperties object: %s", SDKLibrary.getInstance().errorToString(ret)));
            return null;
        }

        return result.result;
    }

    @Nullable
    public IBitsStatus registerBitsListener(@NonNull IBitsListener listener) {
        ResultContainer<IBitsStatus> result = new ResultContainer<>();
        ErrorCode ret = mChatAPI.createBitsStatus(mUserId, listener, result);

        if (ret.failed()) {
            reportError(String.format("Error creating BitStatus object: %s", SDKLibrary.getInstance().errorToString(ret)));
            return null;
        }

        return result.result;
    }

    @NonNull
    public ErrorCode unregisterBitsListener(@NonNull IBitsStatus bitsStatus) {
        return mChatAPI.disposeBitsStatus(bitsStatus);
    }
}

