/* exported UserStatusMonitor */

const { Gio, GLib, GObject, Polari, TelepathyGLib: Tp } = imports.gi;

const { AccountsMonitor } = imports.accountsMonitor;
const { RoomManager } = imports.roomManager;
const Utils = imports.utils;

var UserStatusMonitor = class {
    static getDefault() {
        if (!this._singleton)
            this._singleton = new UserStatusMonitor();
        return this._singleton;
    }

    constructor() {
        this._userTrackers = new Map();
        this._accountsMonitor = AccountsMonitor.getDefault();

        this._accountsMonitor.connect('account-added',
            this._onAccountAdded.bind(this));
        this._accountsMonitor.connect('account-removed',
            this._onAccountRemoved.bind(this));

        this._accountsMonitor.accounts.forEach(a => {
            this._onAccountAdded(this._accountsMonitor, a);
        });
    }

    _onAccountAdded(accountsMonitor, account) {
        if (this._userTrackers.has(account))
            return;

        this._userTrackers.set(account, new UserTracker(account));
    }

    _onAccountRemoved(accountsMonitor, account) {
        this._userTrackers.delete(account);
    }

    getUserTrackerForAccount(account) {
        return this._userTrackers.get(account);
    }
};


const UserTracker = GObject.registerClass({
    Signals: {
        'status-changed': {
            flags: GObject.SignalFlags.DETAILED,
            param_types: [GObject.TYPE_STRING, GObject.TYPE_INT],
        },
        'contacts-changed': {
            flags: GObject.SignalFlags.DETAILED,
            param_types: [GObject.TYPE_STRING],
        },
    },
}, class UserTracker extends GObject.Object {
    _init(account) {
        super._init();

        this._account = account;

        this._baseNickContacts = new Map();
        this._roomData = new Map();
        this._handlerCounter = 0;
        this._app = Gio.Application.get_default();

        this._app.connect('prepare-shutdown', this._onShutdown.bind(this));

        this._roomManager = RoomManager.getDefault();
        this._roomManager.connect('room-added', this._onRoomAdded.bind(this));
        this._roomManager.connect('room-removed', this._onRoomRemoved.bind(this));
    }

    _onShutdown() {
        for (let room of this._roomData.keys())
            this._onRoomRemoved(this._roomManager, room);
    }

    _getRoomContacts(room) {
        return this._roomData.get(room).contactMapping;
    }

    _getRoomHandlers(room) {
        return this._roomData.get(room).handlerMapping;
    }

    _getRoomSignals(room) {
        return this._roomData.get(room).roomSignals;
    }

    _onRoomAdded(roomManager, room) {
        if (room.account !== this._account)
            return;

        this._ensureRoomMappingForRoom(room);

        let roomSignals = [{
            name: 'notify::channel',
            handler: this._onChannelChanged.bind(this),
        }, {
            name: 'member-renamed',
            handler: this._onMemberRenamed.bind(this),
        }, {
            name: 'member-disconnected',
            handler: this._onMemberLeft.bind(this),
        }, {
            name: 'member-kicked',
            handler: this._onMemberLeft.bind(this),
        }, {
            name: 'member-banned',
            handler: this._onMemberLeft.bind(this),
        }, {
            name: 'member-joined',
            handler: this._onMemberJoined.bind(this),
        }, {
            name: 'member-left',
            handler: this._onMemberLeft.bind(this),
        }];

        let signalIds = this._getRoomSignals(room);
        roomSignals.forEach(signal => {
            signalIds.push(room.connect(signal.name, signal.handler));
        });
    }

    _onRoomRemoved(roomManager, room) {
        if (!this._roomData.has(room))
            return;

        this._getRoomSignals(room).forEach(id => room.disconnect(id));
        this._clearUsersFromRoom(room);
        this._roomData.delete(room);
    }

    _onChannelChanged(room) {
        if (!room.channel) {
            this._clearUsersFromRoom(room);
            return;
        }

        let members;
        if (room.type === Tp.HandleType.ROOM)
            members = room.channel.group_dup_members_contacts();
        else
            members = [room.channel.connection.self_contact, room.channel.target_contact];

        /* keep track of initial members in the room, both locally and
        globally*/
        members.forEach(m => this._trackMember(m, room));
    }

    _clearUsersFromRoom(room) {
        let map = this._getRoomContacts(room);
        for (let [, contacts] of map)
            contacts.slice().forEach(m => this._untrackMember(m, room));
    }

    _ensureRoomMappingForRoom(room) {
        if (this._roomData.has(room))
            return;
        this._roomData.set(room, {
            contactMapping: new Map(),
            handlerMapping: new Map(),
            roomSignals: [],
        });
    }

    _onMemberRenamed(room, oldMember, newMember) {
        this._untrackMember(oldMember, room);
        this._trackMember(newMember, room);
    }

    _onMemberJoined(room, member) {
        this._trackMember(member, room);
    }

    _onMemberLeft(room, member) {
        this._untrackMember(member, room);
    }

    _runHandlers(room, member, status) {
        let baseNick = Polari.util_get_basenick(member.alias);
        let roomHandlers = this._getRoomHandlers(room);
        for (let [, info] of roomHandlers) {
            if (!info.nickName || info.nickName === baseNick)
                info.handler(baseNick, status);
        }
    }

    _pushMember(map, baseNick, member) {
        if (!map.has(baseNick))
            map.set(baseNick, []);
        let contacts = map.get(baseNick);
        return contacts.push(member);
    }

    _trackMember(member, room) {
        let baseNick = Polari.util_get_basenick(member.alias);
        let status = Tp.ConnectionPresenceType.AVAILABLE;

        let roomMap = this._getRoomContacts(room);
        if (this._pushMember(roomMap, baseNick, member) === 1)
            this._runHandlers(room, member, status);

        // HACK: Telepathy doesn't notify on member changes for private chats,
        //       so approximate the online status in this case by not adding
        //       the contact to the global map, and removing it from the room
        //       map when the global count drops to 0 (see _untrackMember)
        if (room.type === Tp.HandleType.ROOM) {
            let map = this._baseNickContacts;
            if (this._pushMember(map, baseNick, member) === 1) {
                this.emit(`status-changed::${baseNick}`, baseNick, status);

                if (this._shouldNotifyNick(member.alias))
                    this._notifyNickAvailable(member, room);

                this._setNotifyActionEnabled(member.alias, false);
            }
        }

        this.emit(`contacts-changed::${baseNick}`, member.alias);
    }

    _popMember(map, baseNick, member) {
        let contacts = map.get(baseNick) || [];
        let index = contacts.map(c => c.alias).indexOf(member.alias);
        if (index < 0)
            return [false, contacts.length];
        contacts.splice(index, 1);
        return [true, contacts.length];
    }

    _untrackMember(member, room) {
        let baseNick = Polari.util_get_basenick(member.alias);
        let status = Tp.ConnectionPresenceType.OFFLINE;

        let roomMap = this._getRoomContacts(room);
        let [found, nContacts] = this._popMember(roomMap, baseNick, member);
        if (found && nContacts === 0)
            this._runHandlers(room, member, status);

        let map = this._baseNickContacts;
        [found, nContacts] = this._popMember(map, baseNick, member);
        if (found) {
            if (nContacts === 0) {
                this.emit(`status-changed::${baseNick}`, member.alias, status);
                this._setNotifyActionEnabled(member.alias, true);

                this._app.withdraw_notification(this._getNotifyActionNameInternal(member.alias));

                // HACK: The member is no longer joined any public rooms, so
                //       assume they disconnected and remove them from all
                //       private chats as well
                for (let r of this._roomData.keys())
                    this._untrackMember(member, r);
            }
            this.emit(`contacts-changed::${baseNick}`, member.alias);
        }
    }

    getNickStatus(nickName) {
        let baseNick = Polari.util_get_basenick(nickName);

        let contacts = this._baseNickContacts.get(baseNick) || [];
        return contacts.length === 0
            ? Tp.ConnectionPresenceType.OFFLINE
            : Tp.ConnectionPresenceType.AVAILABLE;
    }

    getNickRoomStatus(nickName, room) {
        let baseNick = Polari.util_get_basenick(nickName);

        this._ensureRoomMappingForRoom(room);

        let contacts = this._getRoomContacts(room).get(baseNick) || [];
        return contacts.length === 0
            ? Tp.ConnectionPresenceType.OFFLINE
            : Tp.ConnectionPresenceType.AVAILABLE;
    }

    lookupContact(nickName) {
        let baseNick = Polari.util_get_basenick(nickName);

        let contacts = this._baseNickContacts.get(baseNick) || [];
        if (!contacts.length)
            return null;

        return contacts.find(c => c.alias === nickName) || contacts[0];
    }

    watchRoomStatus(room, baseNick, callback) {
        this._ensureRoomMappingForRoom(room);

        this._getRoomHandlers(room).set(++this._handlerCounter, {
            nickName: baseNick,
            handler: callback,
        });

        return this._handlerCounter;
    }

    unwatchRoomStatus(room, handlerID) {
        if (!this._roomData.has(room))
            return;
        this._getRoomHandlers(room).delete(handlerID);
    }

    _notifyNickAvailable(member, room) {
        let notification = new Gio.Notification();
        notification.set_title(_('User is online'));
        notification.set_body(_('User %s is now online.').format(member.alias));

        let param = GLib.Variant.new('(ssu)', [
            this._account.get_object_path(),
            room.channel_name,
            Utils.getTpEventTime(),
        ]);
        notification.set_default_action_and_target('app.join-room', param);

        this._app.send_notification(this._getNotifyActionNameInternal(member.alias), notification);
    }

    _shouldNotifyNick(nickName) {
        let actionName = this._getNotifyActionNameInternal(nickName);
        let state = this._app.get_action_state(actionName);
        return state ? state.get_boolean() : false;
    }

    _setNotifyActionEnabled(nickName, enabled) {
        let name = this._getNotifyActionNameInternal(nickName);
        let action = this._app.lookup_action(name);
        if (action)
            action.enabled = enabled;
    }

    _getNotifyActionNameInternal(nickName) {
        let pathSuffix = this._account.get_path_suffix();
        let baseNick = Polari.util_get_basenick(nickName);
        return `notify-user-${pathSuffix}-${baseNick}`;
    }

    getNotifyActionName(nickName) {
        let name = this._getNotifyActionNameInternal(nickName);

        if (!this._app.lookup_action(name)) {
            let status = this.getNickStatus(nickName);
            let enabled = status === Tp.ConnectionPresenceType.OFFLINE;

            let state = new GLib.Variant('b', false);
            let action = new Gio.SimpleAction({
                name,
                enabled,
                state,
            });

            action.connect('notify::enabled', () => {
                if (!action.enabled)
                    action.change_state(GLib.Variant.new('b', false));
            });

            this._app.add_action(action);
        }

        return `app.${name}`;
    }
});
