#!/usr/bin/python3
# This file is part of Epoptes, https://epoptes.org
# Copyright 2010-2020 the Epoptes team, see AUTHORS.
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Epoptes GUI class.
"""
from distutils.version import LooseVersion
import getpass
import locale
import os
import pipes
import random
import socket
import string
import subprocess

from epoptes.ui.reactor import reactor
from epoptes.common.constants import *
from epoptes.common import config
from epoptes.core import logger, structs, wol
from epoptes.ui.about import About
from epoptes.ui.benchmark import Benchmark
from epoptes.ui.client_information import ClientInformation
from epoptes.ui.common import gettext as _
from epoptes.ui.exec_command import ExecCommand
from epoptes.ui.notifications import NotifyQueue
from epoptes.ui.send_message import SendMessage

from gi.repository import Gdk, GdkPixbuf, GLib, Gtk

LOG = logger.Logger(__file__)


class EpoptesGui(object):
    """Epoptes GUI class."""
    def __init__(self):
        # Initialization of general-purpose variables
        self.about = None
        self.benchmark = None
        self.client_information = None
        self.current_macs = subprocess.Popen(
            ['sh', '-c',
             r"""ip -oneline -family inet link show | """
             r"""sed -n 's/.*ether[[:space:]]*\([[:xdigit:]:]*\).*/\1/p';"""
             r"""echo $LTSP_CLIENT_MAC"""],
            stdout=subprocess.PIPE).communicate()[0].decode().lower().split()
        self.current_thumbshots = dict()
        self.daemon = None
        self.displayed_compatibility_warning = False
        self.exec_command = None
        self.imagetypes = {
            'thin': GdkPixbuf.Pixbuf.new_from_file('images/thin.svg'),
            'fat': GdkPixbuf.Pixbuf.new_from_file('images/fat.svg'),
            'standalone': GdkPixbuf.Pixbuf.new_from_file(
                'images/standalone.svg'),
            'offline': GdkPixbuf.Pixbuf.new_from_file('images/offline.svg')}
        self.labels_order = (1, 0)
        self.notify_queue = NotifyQueue(
            'Epoptes',
            '/usr/share/icons/hicolor/scalable/apps/epoptes.svg')
        self.send_message = None
        self.show_real_names = config.settings.getboolean(
            'GUI', 'show_real_names', fallback=False)
        # Thumbshot width and height. Good width defaults are multiples of 16,
        # so that the height is an integer in both 16/9 and 4/3 aspect ratios.
        self.ts_width = config.settings.getint(
            'GUI', 'thumbshots_width', fallback=128)
        self.ts_height = int(self.ts_width/(4/3))
        self.uid = os.getuid()
        self.vncserver = None
        self.vncserver_port = None
        self.vncserver_pwd = None
        self.vncviewer = None
        self.vncviewer_port = None

        self.builder = Gtk.Builder()
        self.builder.add_from_file('epoptes.ui')
        self.get = self.builder.get_object
        # Hide the remote assistance menuitem if epoptes-client isn't installed
        if not os.path.isfile('/usr/share/epoptes-client/remote_assistance.py'):
            self.get('imi_help_remote_support').set_property('visible', False)
            self.get('smi_help_remote_support').set_property('visible', False)
        self.mnu_add_to_group = self.get('mnu_add_to_group')
        self.mni_add_to_group = self.get('mni_add_to_group')
        self.gstore = Gtk.ListStore(str, object, bool)
        self.trv_groups = self.get('trv_groups')
        self.trv_groups.set_model(self.gstore)
        self.mainwin = self.get('wnd_main')
        self.cstore = Gtk.ListStore(str, GdkPixbuf.Pixbuf, object, str)
        self.icv_clients = self.get('icv_clients')
        self.set_labels_order(1, 0, None)
        self.icv_clients.set_model(self.cstore)
        self.icv_clients.set_pixbuf_column(C_PIXBUF)
        self.icv_clients.set_text_column(C_LABEL)
        self.cstore.set_sort_column_id(C_LABEL, Gtk.SortType.ASCENDING)
        self.on_icv_clients_selection_changed(None)
        self.icv_clients.enable_model_drag_source(
            Gdk.ModifierType.BUTTON1_MASK,
            [Gtk.TargetEntry.new("add", Gtk.TargetFlags.SAME_APP, 0)],
            Gdk.DragAction.COPY)
        self.trv_groups.enable_model_drag_dest(
            [("add", Gtk.TargetFlags.SAME_APP, 0)], Gdk.DragAction.COPY)
        self.default_group = structs.Group(
            '<b>'+_('Detected clients')+'</b>', {})
        default_iter = self.gstore.append(
            [self.default_group.name, self.default_group, False])
        self.default_group_ref = Gtk.TreeRowReference.new(
            self.gstore, self.gstore.get_path(default_iter))
        # Connect glade handlers with the callback functions
        self.builder.connect_signals(self)
        self.trv_groups.get_selection().select_path(
            self.default_group_ref.get_path())
        self.get('adj_icon_size').set_value(self.ts_width)
        self.on_scl_icon_size_value_changed(None)
        # Support a global groups.json, writable only by the "administrator"
        self.groups_file = '/etc/epoptes/groups.json'
        if os.access(self.groups_file, os.R_OK):
            # Don't use global groups for the "administrator"
            self.global_groups = not os.access(self.groups_file, os.W_OK)
        else:
            self.groups_file = config.expand_filename('groups.json')
            self.global_groups = False
        try:
            _saved_clients, groups = config.read_groups(self.groups_file)
        except ValueError as exc:
            self.warning_dialog(
                _('Failed to read the groups file:') + '\n'
                + self.groups_file + '\n'
                + _('You may need to restore your groups from a backup!')
                + '\n\n' + str(exc))
            _saved_clients, groups = [], []
        # In global groups mode, groups that start with X- are hidden
        self.x_groups = {}
        if self.global_groups:
            for group in list(groups):
                if group.name.upper().startswith('X-HIDDEN'):
                    if 'X-HIDDEN' not in self.x_groups:
                        self.x_groups['X-HIDDEN'] = []
                    self.x_groups['X-HIDDEN'] = list(set(
                        self.x_groups['X-HIDDEN']
                        + [m.mac for m in group.members]))
                if group.name.upper().startswith('X-'):
                    groups.remove(group)
        if groups:
            self.mni_add_to_group.set_sensitive(True)
        for group in groups:
            self.gstore.append([group.name, group, True])
            mitem = Gtk.MenuItem(label=group.name)
            mitem.show()
            mitem.connect(
                'activate', self.on_imi_clients_add_to_group_activate, group)
            self.mnu_add_to_group.append(mitem)
        self.fill_icon_view(self.get_selected_group()[1])
        self.trv_groups.get_selection().select_path(
            config.settings.getint('GUI', 'selected_group', fallback=0))
        mitem = self.get(config.settings.get(
            'GUI', 'label', fallback='rmi_labels_host_user'))
        if not mitem:
            mitem = self.get('rmi_labels_host_user')
        mitem.set_active(True)
        self.get('cmi_show_real_names').set_active(self.show_real_names)
        self.mainwin.set_sensitive(False)

    def save_settings(self):
        """Helper function for on_imi_file_quit_activate."""
        sel_group = self.gstore.get_path(self.get_selected_group()[0])[0]
        self.gstore.remove(self.gstore.get_iter(
            self.default_group_ref.get_path()))
        if not self.global_groups:
            config.save_groups(self.groups_file, self.gstore)
        settings = config.settings
        if not settings.has_section('GUI'):
            settings.add_section('GUI')

        settings.set('GUI', 'selected_group', str(sel_group))
        settings.set('GUI', 'show_real_names', str(self.show_real_names))
        settings.set('GUI', 'thumbshots_width', str(self.ts_width))
        config.write_ini_file(config.expand_filename('settings'), settings)

    def on_imi_file_quit_activate(self, _widget):
        """Handle imi_file_quit.activate and wnd_main.destroy events."""
        self.save_settings()
        if self.vncserver is not None:
            self.vncserver.kill()
        if self.vncviewer is not None:
            self.vncviewer.kill()
        # noinspection PyUnresolvedReferences
        reactor.stop()

    def on_rmi_labels_host_user_toggled(self, rmi):
        """Handle rmi_labels_host_user.toggled event."""
        self.set_labels_order(1, 0, rmi)

    def on_rmi_labels_host_toggled(self, rmi):
        """Handle rmi_labels_host.toggled event."""
        self.set_labels_order(-1, 0, rmi)

    def on_rmi_labels_user_host_toggled(self, rmi):
        """Handle rmi_labels_user_host.toggled event."""
        self.set_labels_order(0, 1, rmi)

    def on_rmi_labels_user_toggled(self, rmi):
        """Handle rmi_labels_user.toggled event."""
        self.set_labels_order(0, -1, rmi)

    def set_labels_order(self, user_pos, name_pos, rmi):
        """Helper function for on_rmi_labels_*_toggled."""
        # Save the order so all new clients get the selected format
        if rmi:
            config.settings.set('GUI', 'label', Gtk.Buildable.get_name(rmi))
        self.labels_order = (user_pos, name_pos)
        for row in self.cstore:
            self.set_label(row)

    def on_cmi_show_real_names_toggled(self, widget):
        """Handle cmi_show_real_names.toggled event."""
        self.show_real_names = widget.get_active()
        for row in self.cstore:
            self.set_label(row)

    def on_imi_session_boot_activate(self, _widget):
        """Handle imi_session_boot.activate event."""
        clients = self.get_selected_clients()
        if not clients:  # No client selected, send the command to all
            clients = self.cstore
        for client in clients:
            # Make sure that only offline computers will be sent to wol
            client = client[C_INSTANCE]
            if client.is_offline():
                wol.wake_on_lan(client.mac)

    def on_imi_session_logout_activate(self, _widget):
        """Handle imi_session_logout.activate event."""
        self.exec_on_selected_clients(
            ['logout'], mode=EM_SESSION,
            warn=_('Are you sure you want to log off all the users?'))

    def on_imi_session_reboot_activate(self, _widget):
        """Handle imi_session_reboot.activate event."""
        self.exec_on_selected_clients(
            ["reboot"],
            warn=_('Are you sure you want to reboot all the computers?'))

    def on_imi_session_shutdown_activate(self, _widget):
        """Handle imi_session_shutdown.activate event."""
        self.exec_on_selected_clients(
            ["shutdown"],
            warn=_('Are you sure you want to shutdown all the computers?'))

    @staticmethod
    def find_unused_port():
        """Find an unused port."""
        sck = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sck.bind(('', 0))
        sck.listen(1)
        port = sck.getsockname()[1]
        sck.close()
        return port

    def reverse_connection(self, cmd, *args):
        """Helper function for on_imi_broadcasts_*_activate."""
        # Open vncviewer in listen mode
        if self.vncviewer is None or self.vncviewer.poll() is not None:
            self.vncviewer_port = self.find_unused_port()
            # If the user installed ssvnc, prefer it over xvnc4viewer
            if os.path.isfile('/usr/bin/ssvncviewer'):
                self.vncviewer = subprocess.Popen(
                    ['ssvncviewer', '-multilisten',
                     str(self.vncviewer_port-5500)])
            elif os.path.isfile('/usr/bin/xtigervncviewer'):
                self.vncviewer = subprocess.Popen(
                    ['xtigervncviewer', '-listen', str(self.vncviewer_port)])
            elif os.path.isfile('/usr/bin/xvnc4viewer'):
                self.vncviewer = subprocess.Popen(
                    ['xvnc4viewer', '-listen', str(self.vncviewer_port)])
            # Support tigervnc on rpm distributions (LP: #1501747)
            elif os.path.isfile('/usr/share/locale/de/LC_MESSAGES/tigervnc.mo'):
                self.vncviewer = subprocess.Popen(
                    ['vncviewer', '-listen', str(self.vncviewer_port)])
            # The rest of the viewers, like tightvnc
            else:
                self.vncviewer = subprocess.Popen(
                    ['vncviewer', '-listen', str(self.vncviewer_port-5500)])

        # And, tell the clients to connect to the server
        self.exec_on_selected_clients([cmd, self.vncviewer_port] + list(args))

    def on_imi_broadcasts_monitor_user_activate(self, _widget):
        """Handle imi_sbroadcasts_monitor_user.activate event."""
        self.reverse_connection('get_monitored')

    def on_imi_broadcasts_assist_user_activate(self, _widget,
                                               _path=None, _view_column=None):
        """Handle imi_sbroadcasts_assist_user.activate event."""
        if config.settings.getboolean('GUI', 'grabkbdptr', fallback=False):
            self.reverse_connection('get_assisted', 'True')
        else:
            self.reverse_connection('get_assisted')

    def broadcast_screen(self, fullscreen=''):
        """Helper function for on_imi_broadcasts_broadcast_screen*_activate."""
        if self.vncserver is None:
            pwdfile = config.expand_filename('vncpasswd')
            pwd = ''.join(random.sample(
                string.ascii_letters + string.digits, 8))
            subprocess.call(['x11vnc', '-storepasswd', pwd, pwdfile])
            with open(pwdfile, 'rb') as file:
                pwd = file.read()
            self.vncserver_port = self.find_unused_port()
            self.vncserver_pwd = ''.join('\\%o' % c for c in pwd)
            self.vncserver = subprocess.Popen(
                ['x11vnc', '-noshm', '-nopw', '-quiet', '-viewonly', '-shared',
                 '-forever', '-nolookup', '-24to32', '-threads', '-rfbport',
                 str(self.vncserver_port), '-rfbauth', pwdfile])
        # Running `xdg-screensaver reset` as root doesn't reset the D.E.
        # screensaver, so send the reset command to both epoptes processes
        self.exec_on_selected_clients(
            ['reset_screensaver'], mode=EM_SYSTEM_AND_SESSION)
        self.exec_on_selected_clients(
            ["receive_broadcast", self.vncserver_port, self.vncserver_pwd,
             fullscreen], mode=EM_SYSTEM_OR_SESSION)

    def on_imi_broadcasts_broadcast_screen_fullscreen_activate(self, _widget):
        """Handle imi_broadcasts_broadcast_screen_fullscreen.activate event."""
        self.broadcast_screen('true')

    def on_imi_broadcasts_broadcast_screen_windowed_activate(self, _widget):
        """Handle imi_broadcasts_broadcast_screen_windowed.activate event."""
        self.broadcast_screen('')

    def on_imi_broadcasts_stop_broadcasts_activate(self, _widget):
        """Handle imi_broadcasts_stop_broadcasts.activate event."""
        self.exec_on_clients(['stop_receptions'], self.cstore,
                             mode=EM_SYSTEM_AND_SESSION)
        if self.vncserver is not None:
            self.vncserver.kill()
            self.vncserver = None

    # TODO: Should we allow for running arbitrary commands in clients?
    def on_imi_execute_execute_command_activate(self, _widget):
        """Handle imi_execute_execute_command.activate event."""
        if not self.exec_command:
            self.exec_command = ExecCommand(self.mainwin)
        cmd = self.exec_command.run()
        # If Cancel or Close were clicked
        if cmd == '':
            return
        if cmd.startswith("sudo "):
            e_m = EM_SYSTEM
            cmd = cmd[5:]
        else:
            e_m = EM_SESSION
        self.exec_on_selected_clients(['execute', cmd], mode=e_m)

    def on_imi_execute_send_message_activate(self, _widget):
        """Handle imi_execute_send_message.activate event."""
        if not self.send_message:
            self.send_message = SendMessage(self.mainwin)
        params = self.send_message.run()
        if params:
            self.exec_on_selected_clients(['message'] + list(params))

    def open_terminal(self, e_m):
        """Helper function for on_imi_open_terminal_*_activate."""
        clients = self.get_selected_clients()
        # If there is no client selected, send the command to all
        if not clients:
            clients = self.cstore
        for client in clients:
            inst = client[C_INSTANCE]
            if inst.type == 'offline':
                continue
            port = self.find_unused_port()
            user = '--'
            if e_m == EM_SESSION and client[C_SESSION_HANDLE]:
                user = inst.users[client[C_SESSION_HANDLE]]['uname']
            elif e_m == EM_SYSTEM:
                user = 'root'
            title = '%s@%s' % (user, inst.get_name())
            subprocess.Popen(['xterm', '-T', title, '-e', 'socat',
                              'tcp-listen:%d,keepalive=1' % port,
                              'stdio,raw,echo=0'])
            self.exec_on_clients(['remote_term', port], [client], mode=e_m)

    def on_imi_open_terminal_user_locally_activate(self, _widget):
        """Handle imi_open_terminal_user_locally.activate event."""
        self.open_terminal(EM_SESSION)

    def on_imi_open_terminal_root_locally_activate(self, _widget):
        """Handle imi_open_terminal_root_locally.activate event."""
        self.open_terminal(EM_SYSTEM)

    def on_imi_open_terminal_root_remotely_activate(self, _widget):
        """Handle imi_open_terminal_root_remotely.activate event."""
        self.exec_on_selected_clients(['root_term'], mode=EM_SYSTEM)

    def on_imi_restrictions_lock_screen_activate(self, _widget):
        """Handle imi_restrictions_lock_screen.activate event."""
        msg = _("The screen is locked by a system administrator.")
        self.exec_on_selected_clients(['lock_screen', 0, msg])

    def on_imi_restrictions_unlock_screen_activate(self, _widget):
        """Handle imi_restrictions_unlock_screen.activate event."""
        self.exec_on_selected_clients(
            ['unlock_screen'], mode=EM_SESSION_AND_SYSTEM)

    def on_imi_restrictions_mute_sound_activate(self, _widget):
        """Handle imi_restrictions_mute_sound.activate event."""
        self.exec_on_selected_clients(
            ['mute_sound', 0], mode=EM_SYSTEM_AND_SESSION)

    def on_imi_restrictions_unmute_sound_activate(self, _widget):
        """Handle imi_restrictions_unmute_sound.activate event."""
        self.exec_on_selected_clients(
            ['unmute_sound'], mode=EM_SYSTEM_AND_SESSION)

    def on_imi_clients_add_to_group_activate(self, _widget, group):
        """Handle *dynamic* imi_clients_add_to_group.activate event."""
        clients = self.get_selected_clients()
        for client in clients:
            if not group.has_client(client[C_INSTANCE]):
                group.add_client(client[C_INSTANCE])

    def on_imi_clients_remove_from_group_activate(self, _widget):
        """Handle imi_clients_remove_from_group.activate event."""
        clients = self.get_selected_clients()
        group = self.get_selected_group()[1]
        if self.confirmation_dialog(
                _('Are you sure you want to remove the selected client(s)'
                  ' from group "%s"?') % group.name):
            for client in clients:
                group.remove_client(client[C_INSTANCE])
            self.fill_icon_view(self.get_selected_group()[1], True)

    def on_imi_clients_network_benchmark_activate(self, _widget):
        """Handle imi_clients_network_benchmark.activate event."""
        if not self.benchmark:
            self.benchmark = Benchmark(self.mainwin, self.daemon.command)
        self.benchmark.run(self.get_selected_clients() or self.cstore)

    def on_imi_clients_information_activate(self, _widget):
        """Handle imi_clients_information.activate event."""
        if not self.client_information:
            self.client_information = ClientInformation(self.mainwin)
        self.client_information.btn_edit_alias.set_sensitive(
            not self.is_default_group_selected())
        self.client_information.run(
            self.get_selected_clients()[0], self.daemon.command)
        self.set_label(self.get_selected_clients()[0])

    @staticmethod
    def open_url(link):
        """Helper function for on_imi_open_terminal_*_activate."""
        subprocess.Popen(["xdg-open", link])

    def on_imi_help_home_activate(self, _widget):
        """Handle imi_help_home.activate event."""
        self.open_url("https://epoptes.org")

    def on_imi_help_report_bug_activate(self, _widget):
        """Handle imi_help_report_bug.activate event."""
        self.open_url("https://bugs.launchpad.net/epoptes")

    def on_imi_help_ask_question_activate(self, _widget):
        """Handle imi_help_ask_question.activate event."""
        self.open_url("https://answers.launchpad.net/epoptes")

    def on_imi_help_translate_application_activate(self, _widget):
        """Handle imi_help_translate_application.activate event."""
        self.open_url("https://epoptes.org/translations")

    def on_imi_help_live_chat_irc_activate(self, _widget):
        """Handle imi_help_live_chat_irc.activate event."""
        host = socket.gethostname()
        user = getpass.getuser()
        lang = locale.getlocale()[0]
        self.open_url("http://ts.sch.gr/repo/irc?user=%s&host=%s&lang=%s" %
                      (user, host, lang))

    @staticmethod
    def on_imi_help_remote_support_activate(_widget):
        """Handle imi_help_remote_support.activate event."""
        subprocess.Popen('./remote_assistance.py',
                         cwd='/usr/share/epoptes-client')

    def on_imi_help_about_activate(self, _widget):
        """Handle imi_help_about_activate.activate event."""
        if not self.about:
            self.about = About(self.mainwin)
        self.about.run()

    def on_trv_groups_drag_drop(self, _wid, context, drag_x, drag_y, time):
        """Handle trv_groups.drag_drop event."""
        dest = self.trv_groups.get_dest_row_at_pos(drag_x, drag_y)
        if dest is not None:
            path, _pos = dest
            group = self.gstore[path][G_INSTANCE]
            if group is not self.default_group:
                for cln in self.get_selected_clients():
                    cln = cln[C_INSTANCE]
                    if not group.has_client(cln):
                        group.add_client(cln)

        context.finish(True, False, time)
        return True

    def on_trv_groups_drag_motion(self, widget, context, drag_x, drag_y, time):
        """Handle trv_groups.drag_motion event."""
        # Don't allow dropping in the empty space of the treeview, or inside
        # the 'Detected clients' group, or inside the currently selected group
        drag_info = widget.get_dest_row_at_pos(drag_x, drag_y)
        act = 0
        if drag_info:
            path, pos = drag_info
            if pos:
                if path != self.gstore.get_path(self.get_selected_group()[0]) \
                        and path != self.default_group_ref.get_path():
                    act = context.get_suggested_action()
        Gdk.drag_status(context, act, time)
        return True

    def on_crt_group_edited(self, _widget, path, new_name):
        """Handle crt_group.edited event."""
        self.gstore[path][G_LABEL] = new_name
        self.gstore[path][G_INSTANCE].name = new_name
        self.mnu_add_to_group.get_children()[int(path)-1].set_label(new_name)

    def on_trs_groups_changed(self, _treeselection):
        """Handle trs_groups.changed event."""
        self.cstore.clear()
        selected = self.get_selected_group()

        if selected is not None:
            self.fill_icon_view(selected[1])
            path = self.gstore.get_path(selected[0])[0]
            self.mnu_add_to_group.foreach(lambda w: w.set_sensitive(True))
            menuitems = self.mnu_add_to_group.get_children()
            if path != 0 and path-1 < len(menuitems):
                menuitems[path-1].set_sensitive(False)
        else:
            if not self.default_group_ref.valid():
                return
            self.trv_groups.get_selection().select_path(
                self.default_group_ref.get_path())
        self.get('btn_group_remove').set_sensitive(
            not self.is_default_group_selected())
        self.set_move_group_sensitivity()

    def set_move_group_sensitivity(self):
        """Helper function for on_btn_group_*_clicked."""
        selected = self.get_selected_group()
        selected_path = self.gstore.get_path(selected[0])[0]
        blocker = not selected[1] is self.default_group
        self.get('btn_group_up').set_sensitive(blocker and selected_path > 1)
        self.get('btn_group_down').set_sensitive(
            blocker and selected_path < len(self.gstore)-1)

    def on_btn_group_add_clicked(self, _widget):
        """Handle btn_group_add.clicked event."""
        new_group = structs.Group(_('New group'), {})
        itr = self.gstore.append([new_group.name, new_group, True])
        # Edit the name of the newly created group
        self.trv_groups.set_cursor(
            self.gstore.get_path(itr), self.get('tvc_group'), True)
        menuitem = Gtk.MenuItem(new_group.name)
        menuitem.show()
        menuitem.connect(
            'activate', self.on_imi_clients_add_to_group_activate, new_group)
        self.mnu_add_to_group.append(menuitem)

    def on_btn_group_remove_clicked(self, _widget):
        """Handle btn_group_remove.clicked event."""
        group_iter = self.get_selected_group()[0]
        group = self.gstore[group_iter][G_INSTANCE]

        if self.confirmation_dialog(
                _('Are you sure you want to remove group "%s"?') % group.name):
            path = self.gstore.get_path(group_iter)[0]
            self.gstore.remove(group_iter)
            menuitem = self.mnu_add_to_group.get_children()[path-1]
            self.mnu_add_to_group.remove(menuitem)

    def on_btn_group_up_clicked(self, _widget):
        """Handle btn_group_up.clicked event."""
        selected_group_iter = self.get_selected_group()[0]
        path = self.gstore.get_path(selected_group_iter)[0]
        previous_iter = self.gstore.get_iter(path-1)
        self.gstore.swap(selected_group_iter, previous_iter)
        self.set_move_group_sensitivity()
        mitem = self.mnu_add_to_group.get_children()[path-1]
        self.mnu_add_to_group.reorder_child(mitem, path-2)

    def on_btn_group_down_clicked(self, _widget):
        """Handle btn_group_down.clicked event."""
        selected_group_iter = self.get_selected_group()[0]
        path = self.gstore.get_path(selected_group_iter)[0]
        self.gstore.swap(selected_group_iter,
                         self.gstore.iter_next(selected_group_iter))
        self.set_move_group_sensitivity()
        mitem = self.mnu_add_to_group.get_children()[path-1]
        self.mnu_add_to_group.reorder_child(mitem, path)

    def on_icv_clients_selection_changed(self, _widget):
        """Handle icv_clients.selection_changed event."""
        selected = self.get_selected_clients()
        single_client = False
        if len(selected) == 1:
            single_client = True
        self.get('imi_clients_information').set_sensitive(single_client)
        self.get('tlb_clients_information').set_sensitive(single_client)

        if selected:
            self.get('mni_add_to_group').set_sensitive(True)
            self.get('imi_clients_remove_from_group').set_sensitive(
                not self.is_default_group_selected())
        else:
            self.get('mni_add_to_group').set_sensitive(False)
            self.get('imi_clients_remove_from_group').set_sensitive(False)

        if len(selected) > 1:
            self.get('lbl_status').set_text(
                _('%d clients selected' % len(selected)))
        else:
            self.get('lbl_status').set_text('')

    def on_icv_clients_button_press_event(self, widget, event):
        """Handle icv_clients.button_press event."""
        clicked = widget.get_path_at_pos(int(event.x), int(event.y))

        if event.button == 3:
            if widget is self.icv_clients:
                selection = widget
                selected = widget.get_selected_items()
            else:
                selection = widget.get_selection()
                selected = selection.get_selected_rows()[1]
                if clicked:
                    clicked = clicked[0]
            if clicked:
                if clicked not in selected:
                    selection.unselect_all()
                    selection.select_path(clicked)
            else:
                selection.unselect_all()
            if widget is self.icv_clients:
                menu = self.get('mni_clients').get_submenu()
                menu.popup(None, None, None, None, event.button, event.time)
                menu.show()
            return True
        return False

    def on_btn_size_down_clicked(self, _widget):
        """Handle btn_size_down.clicked event."""
        adj = self.get('adj_icon_size')
        adj.set_value(adj.get_value() - adj.props.page_increment)

    def on_btn_size_up_clicked(self, _widget):
        """Handle btn_size_up.clicked event."""
        adj = self.get('adj_icon_size')
        adj.set_value(adj.get_value() + adj.props.page_increment)

    def on_scl_icon_size_value_changed(self, _widget):
        """Handle scl_icon_size.value_changed event."""
        self.ts_width = int(self.get('adj_icon_size').get_value())
        self.ts_height = int(self.ts_width/(4/3))  # Κeep the 4:3 aspect ratio

        # Fast scale all the thumbshots to make the change quickly visible
        old_pixbufs = list(self.imagetypes.values())
        self.imagetypes = {
            'offline': GdkPixbuf.Pixbuf.new_from_file_at_size(
                'images/offline.svg', self.ts_width, self.ts_height),
            'thin': GdkPixbuf.Pixbuf.new_from_file_at_size(
                'images/thin.svg', self.ts_width, self.ts_height),
            'fat': GdkPixbuf.Pixbuf.new_from_file_at_size(
                'images/fat.svg', self.ts_width, self.ts_height),
            'standalone': GdkPixbuf.Pixbuf.new_from_file_at_size(
                'images/standalone.svg', self.ts_width, self.ts_height),
        }
        for row in self.cstore:
            if row[C_PIXBUF] in old_pixbufs:
                row[C_PIXBUF] = self.imagetypes[row[C_INSTANCE].type]
            else:
                new_thumb = row[C_PIXBUF].scale_simple(
                    self.ts_width, self.ts_height,
                    GdkPixbuf.InterpType.NEAREST)
                row[C_PIXBUF] = new_thumb

        # Hack to remove the extra padding that remains after a 'zoom out'
        # TODO: Weird Gtk3 issue, they calculate excess width:
        # https://bugzilla.gnome.org/show_bug.cgi?id=680953
        # https://stackoverflow.com/questions/14090094/what-causes-the-different-display-behaviour-for-a-gtkiconview-between-different
        self.icv_clients.get_cells()[0].set_fixed_size(self.ts_width/4, -1)
        self.icv_clients.check_resize()

    def on_scl_icon_size_button_press_event(self, _widget, event):
        """Make right click reset the thumbshots size."""
        if event.button == 3:
            self.get('adj_icon_size').set_value(128)
            return True
        return False

    # Daemon callbacks
    def connected(self, daemon):
        """Called from uiconnection->Daemon->connectionMade."""
        self.mainwin.set_sensitive(True)
        self.daemon = daemon
        daemon.enumerate_clients().addCallback(self.amp_got_clients)
        self.fill_icon_view(self.get_selected_group()[1])

    def disconnected(self, _daemon):
        """Called from uiconnection->Daemon->connectionLost."""
        self.mainwin.set_sensitive(False)
        # If the reactor is not running at this point it means that we were
        # closed normally.
        # noinspection PyUnresolvedReferences
        if not reactor.running:
            return
        self.save_settings()
        msg = _("Lost connection with the epoptes service.")
        msg += "\n\n" + \
               _("Make sure the service is running and then restart epoptes.")
        dlg = Gtk.MessageDialog(type=Gtk.MessageType.ERROR,
                                buttons=Gtk.ButtonsType.OK, message_format=msg)
        dlg.set_title(_('Service connection error'))
        dlg.run()
        dlg.destroy()
        # noinspection PyUnresolvedReferences
        reactor.stop()

    def amp_client_connected(self, handle):
        """Called from uiconnection->Daemon->client_connected."""
        LOG.w("New connection from", handle)
        dfr = self.daemon.command(handle, 'info')
        dfr.addCallback(lambda r: self.add_client(handle, r.decode()))
        dfr.addErrback(lambda err: LOG.e(
            "Error when connecting client %s: %s" % (handle, err)))

    def amp_client_disconnected(self, handle):
        """Called from uiconnection->Daemon->client_disconnected."""
        def determine_offline(client_):
            """Helper function to call client.set_offline when appropriate."""
            if client_.hsystem == '' and client_.users == {}:
                client_.set_offline()

        LOG.w("Disconnect from", handle)
        client = None
        for client in structs.clients:
            if client.hsystem == handle:
                if self.get_selected_group()[1].has_client(client) \
                        or self.is_default_group_selected():
                    self.notify_queue.enqueue(
                        _("Shut down:"), client.get_name())
                client.hsystem = ''
                determine_offline(client)
                break
            elif handle in client.users:
                if self.get_selected_group()[1].has_client(client) \
                        or self.is_default_group_selected():
                    self.notify_queue.enqueue(
                        _("Disconnected:"),
                        _("%(user)s from %(host)s") %
                        {"user": client.users[handle]['uname'],
                         "host": client.get_name()})
                del client.users[handle]
                determine_offline(client)
                break
            else:
                client = None
        if client is not None:
            for row in self.cstore:
                if row[C_INSTANCE] is client:
                    self.fill_icon_view(self.get_selected_group()[1], True)
                    break

    def amp_got_clients(self, handles):
        """Callback from self.connected=>daemon.enumerate_clients."""
        LOG.w("Got clients:", ', '.join(handles) or 'None')
        for handle in handles:
            dfr = self.daemon.command(handle, 'info')
            dfr.addCallback(
                lambda r, h=handle: self.add_client(h, r.decode(), True))
            dfr.addErrback(lambda err, h=handle: LOG.e(
                "Error when enumerating client %s: %s" % (h, err)))

    def add_to_icon_view(self, client):
        """Properly add a Client class instance to the clients iconview."""
        # If there are one or more users on client, add a new iconview entry
        # for each one of them.
        label = 'uname'
        if self.show_real_names:
            label = 'rname'
        if client.users:
            for hsession, user in client.users.items():
                self.cstore.append(
                    [self.calculate_label(client, user[label]),
                     self.imagetypes[client.type], client, hsession])
                self.ask_thumbshot(hsession, True)
        else:
            self.cstore.append(
                [self.calculate_label(client),
                 self.imagetypes[client.type], client, ''])

    def fill_icon_view(self, group, keep_selection=False):
        """Fill the clients iconview from a Group class instance."""
        if keep_selection:
            selection = [row[C_INSTANCE] for row in
                         self.get_selected_clients()]
        else:
            selection = None
        self.cstore.clear()
        if self.is_default_group_selected():
            clients_list = [client for client in structs.clients
                            if client.type != 'offline']
        else:
            clients_list = group.get_members()
        # Add the new clients to the iconview
        for client in clients_list:
            self.add_to_icon_view(client)
        if selection:
            for row in self.cstore:
                if row[C_INSTANCE] in selection:
                    self.icv_clients.select_path(row.path)
                    selection.remove(row[C_INSTANCE])

    def is_default_group_selected(self):
        """Return True if the default group is selected"""
        if self.get_selected_group():
            return self.get_selected_group()[1] is self.default_group
        return True

    def get_selected_group(self):
        """Return a 2-tuple containing the itr and the instance
        for the currently selected group."""
        itr = self.trv_groups.get_selection().get_selected()[1]
        if itr:
            return itr, self.gstore[itr][G_INSTANCE]
        return None

    def add_client(self, handle, reply, already=False):
        """Callback after running `info` on a client."""
        # already is True if the client was started before epoptes
        LOG.w("add_client's been called for", handle)
        try:
            info = {}
            for line in reply.strip().split('\n'):
                key, value = line.split('=', 1)
                info[key.strip()] = value.strip()
            user, host, _ip, mac, type_, uid, version, name = \
                info['user'], info['hostname'], info['ip'], info['mac'], \
                info['type'], int(info['uid']), info['version'], info['name']
        except (KeyError, ValueError) as exc:
            LOG.e("  Can't extract client information, won't add this client",
                  exc)
            return False

        # Support hiding clients in an X-Hidden group
        if self.global_groups and 'X-HIDDEN' in self.x_groups:
            if mac in self.x_groups['X-HIDDEN']:
                LOG.w("  X-HIDDEN: Won't add this client to my lists")
                return False

        # Check if the incoming client is the same with the computer in which
        # epoptes is running, so we don't add it to the list.
        if (mac in self.current_macs) and ((uid == self.uid) or (uid == 0)):
            LOG.w("  SAME-PC: Won't add this client to my lists")
            return False

        # Compatibility check
        if LooseVersion(version) < LooseVersion(COMPATIBILITY_VERSION):
            self.daemon.command(
                handle, "die 'Incompatible Epoptes server version!'")
            if not self.displayed_compatibility_warning:
                self.displayed_compatibility_warning = True
                self.warning_dialog(_(
                    """A connection attempt was made by a client with"""
                    """ version %s, which is incompatible with the current"""
                    """ epoptes version.\n\nYou need to update your clients"""
                    """ to the latest epoptes-client version.""") % version)
            return False
        sel_group = self.get_selected_group()[1]
        client = None
        for inst in structs.clients:
            # Find if the new handle is a known client
            if mac == inst.mac:
                client = inst
                LOG.w('  Old client: ', end='')
                break
        if client is None:
            LOG.w('  New client: ', end='')
            client = structs.Client(mac=mac)
        LOG.w('hostname=%s, type=%s, uid=%s, user=%s' %
              (host, type_, uid, user))

        # Update/fill the client information
        client.type, client.hostname = type_, host
        if uid == 0:
            # This is a root epoptes-client
            client.hsystem = handle
        else:
            # This is a user epoptes-client
            client.add_user(user, name, handle)
            if not already and (sel_group.has_client(client)
                                or self.is_default_group_selected()):
                self.notify_queue.enqueue(
                    _("Connected:"),
                    _("%(user)s on %(host)s") % {"user": user, "host": host})
        if sel_group.has_client(client) or self.is_default_group_selected():
            self.fill_icon_view(sel_group, True)

        return True

    def set_label(self, row):
        """Set the appropriate label for a client icon."""
        inst = row[C_INSTANCE]
        if row[C_SESSION_HANDLE]:
            label = 'uname'
            if self.show_real_names:
                label = 'rname'
            user = row[C_INSTANCE].users[row[C_SESSION_HANDLE]][label]
        else:
            user = ''
        row[C_LABEL] = self.calculate_label(inst, user)

    def calculate_label(self, client, username=''):
        """Return the iconview label from a hostname/alias and a username,
        according to the user options.
        """
        user_pos, name_pos = self.labels_order

        alias = client.get_name()
        if username == '' or user_pos == -1:
            return alias
        else:
            if user_pos == 0:
                label = username
                if name_pos == 1:
                    label += " (%s)" % alias
            else:  # name_pos == 0
                label = alias
                if user_pos == 1:
                    label += " (%s)" % username
            return label

    def ask_thumbshot(self, handle, first_time=False):
        """Ask a client for a thumbshot, every 5 seconds."""
        # Should always return False to prevent glib from calling us again
        if first_time:
            if handle not in self.current_thumbshots:
                # We started asking for thumbshots, but didn't yet get one
                self.current_thumbshots[handle] = None
            else:
                # Reuse the existing thumbshot
                if not self.current_thumbshots[handle] is None:
                    for i in self.cstore:
                        if handle == i[C_SESSION_HANDLE]:
                            self.cstore[i.path][C_PIXBUF] = \
                                self.current_thumbshots[handle]
                            break
                return False
        # TODO: Implement this using Gtk.TreeRowReferences instead of
        # searching the whole model (Need to modify exec_on_clients)
        for client in self.cstore:
            if handle == client[C_SESSION_HANDLE]:
                self.exec_on_clients(
                    ['thumbshot', self.ts_width, self.ts_height],
                    handles=[handle], reply=self.got_thumbshot)
                return False
        # That handle is no longer in the cstore, remove it
        if handle in self.current_thumbshots:
            del self.current_thumbshots[handle]
        return False

    def got_thumbshot(self, handle, reply):
        """Callback after running`thumbshot` on a client."""
        for i in self.cstore:
            if handle == i[C_SESSION_HANDLE]:
                # Ask for a new thumbshot THUMBSHOT_MS msec after the last one.
                # So if the client is too stressed and needs e.g. 7 secs to
                # send a thumbshot, we'll ask for one every 12 secs.
                GLib.timeout_add(int(config.system['THUMBSHOT_MS']),
                    self.ask_thumbshot, handle)
                LOG.d("I got a thumbshot from %s." % handle)
                if not reply:
                    return
                try:
                    rowstride, size, pixels = reply.split(b'\n', 2)
                    rowstride = int(rowstride)
                    width, height = [int(i) for i in size.split(b'x')]
                except ValueError:
                    LOG.e("Bad thumbshot header")
                    return
                # GLib.Bytes.new and .copy() avoid memory leak and crash (#110)
                pxb = GdkPixbuf.Pixbuf.new_from_bytes(
                    GLib.Bytes.new(pixels), GdkPixbuf.Colorspace.RGB, False, 8,
                    width, height, rowstride)
                pxb = pxb.copy()
                self.current_thumbshots[handle] = pxb
                self.cstore[i.path][C_PIXBUF] = pxb
                return
        # That handle is no longer in the cstore, remove it
        if handle in self.current_thumbshots:
            del self.current_thumbshots[handle]

    def get_selected_clients(self):
        """Return a list of the clients selected in the iconview."""
        selected = self.icv_clients.get_selected_items()
        items = []
        for i in selected:
            items.append(self.cstore[i])
        return items

    def exec_on_clients(
            self, command, clients=None, reply=None, mode=EM_SESSION_OR_SYSTEM,
            handles=None, warning='', params=None):
        """Execute a command on all (or on the provided) clients."""
        # reply should be a method in which the result will be sent
        if clients is None:
            clients = []
        if handles is None:
            handles = []
        if params is None:
            params = []
        # "if self.cstore" is wrong as it's a Gtk.ListStore, not a dict.
        # pylint: disable=len-as-condition
        if len(self.cstore) == 0:
            LOG.d('No clients')
            return

        if isinstance(command, list) and len(command) > 0:
            command = '%s %s' % (command[0], ' '.join(
                [pipes.quote(str(x)) for x in command[1:]]))

        if (clients != [] or handles != []) and warning != '':
            if not self.confirmation_dialog(warning):
                return
        if clients == [] and handles != []:
            for handle in handles:
                cmd = self.daemon.command(handle, str(command))
                # TODO: do we need errbacks even when no reply?
                if reply:
                    cmd.addCallback(
                        lambda r, h=handle, p=params: reply(h, r, *p))
                    cmd.addErrback(lambda err, h=handle: LOG.e(
                        "Error when executing command %s on client %s: %s" %
                        (command, h, err)))

        for client in clients:
            sent = False
            for e_m in mode:
                if e_m == EM_SESSION_ONLY:
                    handle = client[C_SESSION_HANDLE]
                elif e_m == EM_SYSTEM_ONLY:
                    handle = client[C_INSTANCE].hsystem
                else:  # e_m == EM_EXIT_IF_SENT
                    if sent:
                        break
                    else:
                        continue
                if handle == '':
                    continue
                sent = True
                cmd = self.daemon.command(handle, str(command))
                # TODO: do we need errbacks even when no reply?
                if reply:
                    cmd.addCallback(
                        lambda r, h=handle, p=params: reply(h, r, *p))
                    cmd.addErrback(lambda err: LOG.e(
                        "Error when executing command %s on client %s: %s" %
                        (command, handle, err)))

    def exec_on_selected_clients(
            self, command, reply=None, mode=EM_SESSION_OR_SYSTEM, warn=''):
        """Execute a command on the selected clients."""
        clients = self.get_selected_clients()
        if not clients:  # No client selected, send the command to all
            clients = self.cstore
        else:  # Show the warning only when no clients are selected
            warn = ''
        self.exec_on_clients(command, clients, reply, mode, warning=warn)

    def confirmation_dialog(self, text):
        """Show a Yes/No dialog, returning True/False accordingly."""
        dlg = Gtk.MessageDialog(
            self.mainwin, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO,
            text, title=_('Confirm action'))
        resp = dlg.run()
        dlg.destroy()
        return resp == Gtk.ResponseType.YES

    def warning_dialog(self, text):
        """Show a warning dialog."""
        dlg = Gtk.MessageDialog(
            self.mainwin, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.CLOSE,
            text, title=_('Warning'))
        dlg.run()
        dlg.destroy()
