#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
#    bl-exit: Bunsenlabs exit dialog, offering various exit options
#     via both GUI and CLI
#    Copyright (C) 2012 Philip Newborough  <corenominal@corenominal.org>
#    Copyright (C) 2016 xaos52  <xaos52@gmail.com>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.

from __future__ import print_function

import os
display = os.environ.get('DISPLAY') is not None

import sys
import getpass
import subprocess
import dbus
import struct
import ConfigParser

__me__ = 'bl-exit'
__version__ = '2.2.0'

# Translate command-line option to method - command line only
actionToMethod = dict(
    cancel='Cancel', c='Cancel',
    logout='Logout', l='Logout',
    suspend='Suspend', s='Suspend',
    hybridsleep='HybridSleep', y='HybridSleep',
    hibernate='Hibernate', i='Hibernate',
    reboot='Reboot', b='Reboot',
    poweroff='PowerOff', p='PowerOff'
)


class CanDoItError(Exception):
    pass


class BlexitBase(object):

    def __init__(self):
        self.dbus_iface = None

    def setup_dbus_connection(self):
        try:
            bus = dbus.SystemBus()
            dbus_object = bus.get_object('org.freedesktop.login1',
                                         '/org/freedesktop/login1')
            self.dbus_iface = dbus.Interface(dbus_object,
                                             'org.freedesktop.login1.Manager')
        except bus.DBusException as e:
            self.on_error(str(e))

    def can_do_action(self, action):
        # There is no 'CanLogout' method
        if action == "Logout":
            return "yes"
        actionMethod = "Can{}".format(action)
        response = self.send_dbus(actionMethod)
        return str(response)

    def do_action(self, action):
        print_message("do_action: {}".format(action))
        self.send_dbus(action)

    def send_dbus(self, method):
        try:
            if self.dbus_iface is None:
                self.setup_dbus_connection()
            if method[:3] == "Can":
                command = "self.dbus_iface.{}()".format(method)
            else:
                command = "self.dbus_iface.{}(['True'])".format(method)
            response = eval(command)
            return str(response)
        except dbus.DBusException as e:
            self.on_error(str(e))

    def on_error(self, string):
        print_message("{} {}".format(__me__, string))
        sys.exit(1)

    def on_warning(self, string):
        print_message("{} {}".format(__me__, string))

    def openbox_exit(self):
        subprocess.check_output(["openbox", "--exit"])

    def logout(self):
        try:
            self.openbox_exit()
        except subprocess.CalledProcessError as e:
            self.on_error(e.output)

    def action_from_command_line(self, action):
        try:
            self.do_action(action)
        except (subprocess.CalledProcessError, CanDoItError, KeyError) as e:
            self.on_error(str(e))

    def main(self):
        opts = get_options()
        if opts.logout:
            self.logout()
        else:
            if opts.suspend:
                action = "suspend"
            elif opts.hibernate:
                action = "hibernate"
            elif opts.hybridsleep:
                action = "hybridsleep"
            elif opts.reboot:
                action = "reboot"
            elif opts.poweroff:
                action = "poweroff"
            self.setup_dbus_connection()
            self.action_from_command_line(actionToMethod[action])

if display:
    """Testing for display here because we want to be able to run the script
    in a non-graphical environment as well. Without the test, importing
    gtk.Window in a non-graphical environment spits out some errors and crashes
    the application."""
    import pygtk
    pygtk.require('2.0')
    import gtk
    from time import sleep


    class Blexit(BlexitBase):
        """A dialog offering the user to log out, suspend, reboot or shut down.
        """

        def __init__(self, cp, config_file):
            BlexitBase.__init__(self)
            self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
            self.window.set_name('blexit')
            self.cp = cp
            self.config_file = config_file
            self.debug = True
            self.selectedAction = None
            self.window.set_decorated(True)
            self.window.connect("delete_event", self.destroy)
            self.window.connect("destroy_event", self.destroy)
            self.window.set_resizable(False)
            self.window.set_keep_above(True)
            self.window.stick()
            self.window.set_position(gtk.WIN_POS_CENTER)
            windowicon = self.window.render_icon(gtk.STOCK_QUIT, gtk.ICON_SIZE_DIALOG)
            self.window.set_icon(windowicon)

        def configure(self):
            if self.config_file:
                try:
                    self.cp.read(self.config_file)
                except ConfigParser.ParsingError as e:
                    print_message("{}: {}".format(__me__, str(e)))
                    sys.exit(1)
            else:
                # No config file present:
                # self.cp.add_section("Default")
                # NOTE: add_section raises value error when the section name
                # evaluates to DEFAULT (or any of its case-insensitive
                # variants)
                for section in "hibernate", "hybridsleep":
                    self.cp.add_section(section)
                    self.cp.set(section, "show", "never")
                for section in "cancel", "logout", "suspend", "reboot", "poweroff":
                    self.cp.add_section(section)
                    self.cp.set(section, "show", "always")

        def set_custom_style(self):
            try:
                stylesdir = self.cp.get('style', 'dir')
                rcfile = self.cp.get('style', 'rcfile')
                stylerc = os.path.join(os.path.dirname(self.config_file), stylesdir, rcfile)
                if not os.path.isfile(stylerc):
                    self.on_debug("custom style rc file does not exist")
                    return None
                gtk.rc_parse(stylerc)
                settings = gtk.settings_get_for_screen(self.window.get_screen())
                gtk.rc_reset_styles(settings)
            except:
                self.on_debug("custom style not configured or parse error")
                pass

        def construct_ui(self):
            self.window.set_title("Log out " + getpass.getuser() + "?")
            self.window.height = 80

            # Cancel key (Escape)
            accelgroup = gtk.AccelGroup()
            key, mod = gtk.accelerator_parse('Escape')
            accelgroup.connect_group(key, mod, gtk.ACCEL_VISIBLE,
                                     gtk.main_quit)
            self.window.add_accel_group(accelgroup)

            self.button_width = 100
            self.button_height = 50
            self.button_border_width = 4
            self.window.set_border_width(10)
            self.button_box = gtk.HButtonBox()
            self.button_box.set_spacing(0)
            self.button_box.set_layout(gtk.BUTTONBOX_SPREAD)
            self.build_button_visibility_array()
            visible_button_count = 0
            for button in self.bva:
                (action, label, actionfunc, method, show, onError) = button
                if not show == 0:
                    visible_button_count += 1
                    self.add_button(show, actionfunc, label=label)
            self.status = gtk.Label()
            label_box = gtk.HBox()
            label_box.pack_start(self.status)

            # allow for username of about twenty characters: len(title) + 200
            # approximation: counting characters, not size of rendered string
            if visible_button_count == 0:
                self.window.width = len(title) + 300
            elif visible_button_count <= 2:
                self.window.width = max (
                    (self.button_width + 10) * visible_button_count,
                    len(title) + 300)
            else:
                self.window.width = -1
            self.window.set_size_request(self.window.width, self.window.height)
            vbox = gtk.VBox()
            vbox.pack_start(self.button_box)
            vbox.pack_start(label_box)
            self.window.add(vbox)
            self.window.show_all()

        def destroy(self, widget=None, event=None, data=None):
            self.window.hide_all()
            gtk.main_quit()

        def build_button_visibility_array(self):
            """Determine button visibily using bl-exit configuration file.
            Build self.bva, an array of tuples, one entry per button,
            containing (action, label, actionfunction, actionMethod, show,
            onerror)
            """
            self.bva = []
            bva = [
                ('cancel', '_Cancel', self.cancel_action),
                ('logout', '_Log out', self.logout_action),
                ('suspend', '_Suspend', self.suspend_action),
                ('hibernate', 'H_ibernate', self.hibernate_action),
                ('hybridsleep', 'H_ybrid sleep', self.hybridsleep_action),
                ('reboot', 'Re_boot', self.reboot_action),
                ('poweroff', '_Power off', self.shutdown_action)
            ]
            show_values = dict(never=0, always=1, maybe=2)
            """Values that the 'show' keyword can take in the configuration
            file."""
            onerror_values = dict(novisual=0, visual=1)
            """Values that the 'onerror' keyword can take in the configuration
            file."""
            # Per button default settings
            per_button_show_defaults = dict(
                cancel='always',
                logout='always',
                suspend='always',
                hibernate='never',
                hybridsleep='never',
                reboot='always',
                poweroff='always'
            )
            for (action, label, actionfunction) in bva:
                # Defaults.
                show = show_values[per_button_show_defaults[action]]
                onError = onerror_values['novisual']
                for section in ['default', action]:
                    try:
                        try:
                            getshow = self.cp.get(section, 'show')
                            if getshow in show_values:
                                show = show_values[getshow]
                                if show == 2:
                                    candoit = self.can_do_action(
                                        actionToMethod[action])
                                    if not candoit == 'yes':
                                        show = 3
                            self.on_debug("config section {} show={}".format(section,show))
                        except ConfigParser.NoOptionError as e:
                            self.on_debug("config section {}  no option show".format(section))
                            pass

                        try:
                            getonerror = self.cp.get(section, 'onerror')
                            if getonerror in onerror_values:
                                onError = onerror_values[getonerror]
                            self.on_debug("config section {} onerror={}".format(section,onError))
                        except ConfigParser.NoOptionError as e:
                            self.on_debug("config section {} no option onerror".format(section))
                            pass
                    except ConfigParser.NoSectionError as e:
                        self.on_debug("config section {} not present".format(section))
                        pass

                self.bva.append(tuple([action, label, actionfunction,
                                       actionToMethod[action], show,
                                       onError]))

        def main(self):
            self.configure()
            self.set_custom_style()
            self.construct_ui()
            gtk.main()

        def add_button(self, show, action, label=None, stock=None):
            if stock is not None:
                button = gtk.Button(stock=stock)
            else:
                button = gtk.Button(label=label)
            button.set_size_request(self.button_width, self.button_height)
            if show == 3:
                button.set_sensitive(False)
            button.set_border_width(self.button_border_width)
            button.connect("clicked", action)
            self.button_box.pack_start(button)

        def disable_buttons(self):
            self.button_box.foreach(lambda button:
                                    button.set_sensitive(False))

        def cancel_action(self, button):
            self.disable_buttons()
            gtk.main_quit()

        def get_onerror(self):
            onerror = 0
            if self.selectedAction is not None:
                for item in self.bva:
                    (action, label, actionfunction, actionMethod, show,
                     onerror) = item
                    if action == self.selected_action:
                        return onerror
            return onerror

        def on_error(self, e):
            onerror = self.get_onerror()
            if onerror == 0:
                print_message("{}: {}".format(__me__, str(e)))
                sys.exit(1)
            else:
                emDialog = gtk.MessageDialog(parent=None, flags=0,
                                             type=gtk.MESSAGE_INFO,
                                             buttons=gtk.BUTTONS_OK,
                                             message_format=None)
                if emDialog:
                    emDialog.set_markup("{}".format(e))
                    emDialog.run()

        def on_warning(self, e):
            e = "{} {}".format(__me__, str(e))
            if self.debug:
                e = "DEBUG {}".format(e)
            print_message(e)

        def on_debug(self, e):
            if self.debug:
                self.on_warning(e)

        def cancel_action(self, button):
            self.destroy()

        def logout_action(self, button):
            self.disable_buttons()
            self.selected_action = 'logout'
            self.status.set_label("Exiting Openbox, please standby...")
            self.openbox_exit()
            self.destroy()

        def suspend_action(self, button):
            self.disable_buttons()
            self.selected_action = 'suspend'
            self.status.set_label("Suspending, please standby...")
            self.do_action("Suspend")
            self.destroy()

        def hibernate_action(self, button):
            self.disable_buttons()
            self.selected_action = 'hibernate'
            self.status.set_label("Hibernating, please standby...")
            self.do_action("Hibernate")
            self.destroy()

        def hybridsleep_action(self, button):
            self.disable_buttons()
            self.selected_action = 'hybridsleep'
            self.status.set_label("Hibernating + Sleeping, please standby...")
            self.do_action("HybridSleep")
            self.destroy()

        def reboot_action(self, button):
            self.disable_buttons()
            self.selected_action = 'reboot'
            self.status.set_label("Rebooting, please standby...")
            self.do_action("Reboot")
            self.destroy()

        def shutdown_action(self, button):
            self.disable_buttons()
            self.selected_action = 'poweroff'
            self.status.set_label("Shutting down, please standby...")
            self.do_action("PowerOff")
            self.destroy()


    class BlexitThemeDetail():
        """
        :param value
        Value for the theme detail
        :param required
        When a theme detail is not configured for a theme, and the detail
        is configured as being required, the default detail value is substituted.
        When required is False, nothing is substituted and the detail is not set.
        Sane defaults are used.
        :param value_type
        'int' and 'float' are recognized.
        All else defaults to 'string
        """
        def __init__(self, value, required, value_type):
            self.value = value
            self.required = required
            self.value_type = value_type

    default_theme_settings = dict(
        name=BlexitThemeDetail('Dark Theme', False, 'string'),
        author=BlexitThemeDetail('MerlinElMago', False, 'string'),
        dialogHeight=BlexitThemeDetail(120, False, 'int'),
        sleepDelay=BlexitThemeDetail(0.003, False, 'float'),
        overallOpacity=BlexitThemeDetail(100, False, 'int'),
        buttonSpacing=BlexitThemeDetail(10, False, 'int'),
        iconpath=BlexitThemeDetail('/usr/share/bunsen-exit', True, 'string'),
        buttonImageCancel=BlexitThemeDetail('cancel.png', False, 'string'),
        buttonImagePowerOff=BlexitThemeDetail('poweroff.png', False, 'string'),
        buttonImageReboot=BlexitThemeDetail('reboot.png', False, 'string'),
        buttonImageSuspend=BlexitThemeDetail('sleep.png', False, 'string'),
        buttonImageLogout=BlexitThemeDetail('logout.png', False, 'string'),
        buttonImageHybridSleep=BlexitThemeDetail('hibernate.png', False, 'string'),
        buttonImageHibernate=BlexitThemeDetail('hibernate.png', False, 'string'),
        windowWidthAdjustment=BlexitThemeDetail(0, False, 'int')
    )


    class BlexitTheme():
        def __init__(self, theme, settings, blexit):
            self.theme = theme
            self.settings = settings
            self.blexit = blexit

        def set_detail(self, key, value):
            self.settings[key] = value

        def set_details_from_config(self, cp, default_theme):
            for key in default_theme_settings.iterkeys():
                default_theme_detail = default_theme_settings[key]
                try:
                    config_value = cp.get(self.theme, key)
                except ConfigParser.NoOptionError as e:
                    self.blexit.on_debug("theme config option {} is not set for theme {}".format(key, self.theme))
                    config_value = None
                    pass
                if config_value is not None:
                    if default_theme_detail.value_type == 'int':
                        try:
                            config_value = int(config_value)
                        except:
                            self.blexit.on_debug("theme config option {} is not an int".format(key, self.theme))
                            config_value = default_theme_detail.value
                    elif default_theme_detail.value_type == 'float':
                        try:
                            default_theme_detail.config_value =float(config_value)
                        except:
                            self.bl-exit.on_debug("theme config option {} is not a float".format(key, self.theme))
                            config_value = default_theme_detail.value
                else:
                    if default_theme_detail.required:
                        config_value = default_theme_detail.value
                if config_value is not None:
                    self.set_detail(key, config_value)


    class BlexitMerlin(Blexit):
        """A dialog offering the user to log out, suspend, reboot or shut down.
        With a graphical UI initially developed by MerlinElMago.
        :param cp: ConfigParser instance
        :param config_file: path to blexit config file
        """
        def __init__(self, cp, config_file):
            Blexit.__init__(self, cp, config_file)
            self.window.set_decorated(False)
            self.tooltips = gtk.Tooltips()

        def configure(self):
            Blexit.configure(self)
            default_theme = BlexitTheme('dark', default_theme_settings, self)
            self.configured_theme = BlexitTheme(self.cp.get('theme', 'theme'), dict(), self)
            self.configured_theme.set_details_from_config(self.cp, default_theme)
            print_message('Loading theme \'' + self.configured_theme.settings.get('name', self.configured_theme.theme) + '\' by ' +
                          self.configured_theme.settings.get('author', 'not set'))

        def construct_ui(self):
            # get width of the monitor where the cursor is
            width = 800
            try:
                display=gtk.gdk.Display(gtk.gdk.get_display())
                screen, x, y, flags=display.get_pointer()
                curmon = screen.get_monitor_at_point(x, y)
                _, _, width, _ = screen.get_monitor_geometry(curmon)
            except:
                self.bl_exit.on_error('Error in construct_ui: Not running under X')
            finally:
                del x, y, display, screen, curmon

            try:
                _width_adjustment = int(self.configured_theme.settings.get('windowWidthAdjustment', 0))
                if abs(_width_adjustment) < width:
                    width -= abs(_width_adjustment)
                if width < 0:
                    width = -1
            except:
                self.on_debug('Problem with windowWidthAdjustment')

            # self.icon_heights is initialized here, and appended to in add_button
            # Delay setting window dimensions until after building self.bva
            try:
                self.icon_heights = [int(self.configured_theme.settings.get('dialogHeight'))]
            except:
                self.on_debug("dialogHeight is not set or is not an int")
                self.icon_heights = []
                pass

            # Cancel key (Escape)
            accelgroup = gtk.AccelGroup()
            key, mod = gtk.accelerator_parse('Escape')
            accelgroup.connect_group(key, mod, gtk.ACCEL_VISIBLE, gtk.main_quit)
            self.window.add_accel_group(accelgroup)

            self.button_box = gtk.HBox()
            self.button_box.set_spacing(0)

            try:
                self.button_box.set_size_request(-1, int(self.configured_theme.settings.get('dialogHeight', -1)))
            except:
                self.on_debug("dialogHeight is not set or is not an int")
                pass

            try:
                self.button_box.set_spacing(int(self.configured_theme.settings.get('buttonSpacing', 0)))
            except:
                self.on_debug("dialogHeight is not set or is not an int")
                pass

            self.build_button_visibility_array()
            for button in self.bva:
                (action, label, actionfunc, method, show, onError) = button
                if not show == 0:
                    self.add_button(show, actionfunc, label=label, btype=actionToMethod[action])
            self.status = gtk.Label()

            if len(self.icon_heights) > 0:
                self.dialogHeight = max(self.icon_heights)
            else:
                self.dialogHeight = -1
            self.window.set_size_request(width, self.dialogHeight)

            vbox = gtk.VBox()
            vbox.pack_start(self.button_box)
            self.window.add(vbox)
            self.window.set_opacity(0)
            self.window.show_all()
            try:
                for o in range(1, int(self.configured_theme.settings.get('overallOpacity'))):
                    sleep(float(self.configured_theme.settings.get('sleepDelay')))
                    while gtk.events_pending():
                        gtk.main_iteration(False)
                        self.window.set_opacity(float(o)/100.0)
            except:
                self.on_debug("Opacity is not fully configured")
                pass
            self.window.set_keep_above(True)

        def main(self):
            self.configure()
            self.set_custom_style()
            self.construct_ui()
            gtk.main()

        def add_button(self, show, action, label=None, btype=None):

            def find_image_file_for_button():
                _filename = self.configured_theme.settings.get('buttonImage' + str(btype), 'nonexistant')
                if _filename is None:
                    return None
                _iconpath = self.configured_theme.settings.get('iconpath')
                if _iconpath is None:
                    return None
                for _dir in _iconpath.split(os.pathsep):
                    _dir = os.path.expanduser(_dir)
                    if os.path.exists(os.path.join(_dir, _filename)):
                        return os.path.join(_dir, _filename)

            button = gtk.Button()
            button.set_relief(gtk.RELIEF_NONE)
            image = gtk.Image()
            _filename = find_image_file_for_button()
            if _filename is not None:
                image.set_from_file(_filename)
                self.icon_heights.append(self.get_image_info(_filename))
            else:
                image.set_from_file('/usr/share/gtk-doc/html/pygtk/icons/stock_broken_image_24.png')
                self.on_warning("image file for '{}' not found.".format(str(btype)))
            button.set_image(image)

            if show == 3:
                button.set_sensitive(False)
            button.set_border_width(0)
            button.connect("clicked", action)
            self.button_box.pack_start(button, expand=True, fill=True, padding=0)

            self.tooltips.set_tip(button, str(btype))

        def get_image_info(self, img_path):
            '''Test if icon is png, and get icon height(px)'''
            height = -1
            try:
                with open(img_path, 'rb') as f:
                    data = f.read()
                if data[:8] == '\211PNG\r\n\032\n' and (data[12:16] == 'IHDR'): # check if png
                    _, height = struct.unpack('>LL', data[16:24])
            except:
                self.on_warning("get_image_info failed for '{}'".format(img_path))
            return height


def print_message(m):
        print (str(m), file=sys.stderr)

def get_options():
    result = None
    import argparse
    parser = argparse.ArgumentParser(description="Bunsenlabs exit")
    if display:
        parser.add_argument("-l", "--logout", help="Log out",
                            action="store_true")
    parser.add_argument("-s", "--suspend", help="Suspend",
                        action="store_true")
    parser.add_argument("-i", "--hibernate", help="Hibernate",
                        action="store_true")
    parser.add_argument("-y", "--hybridsleep", help="Hybrid sleep",
                        action="store_true")
    parser.add_argument("-b", "--reboot", help="Reboot",
                        action="store_true")
    parser.add_argument("-p", "--poweroff", help="Power off",
                        action="store_true")
    parser.parse_args(sys.argv[1:])
    """No check if more than one option was specified. Take the first option and
    discard the other"""
    result = parser.parse_args()
    return result

def get_config_file():
    """Determine config directory: first try the environment variable
    XDG_CONFIG_HOME according to XDG specification and as a fallback
    use ~/.config/bl-exit. Use /etc/xdg/bl-exit/bl-exitrc as a last
    resort."""
    config_file = None
    config_dirs = []
    xdg_config_dir = os.getenv('XDG_CONFIG_HOME')
    if xdg_config_dir:
        config_dirs.append(xdg_config_dir)
    user_config_dir = os.path.expanduser('~/.config')
    try:
        if not (xdg_config_dir and os.path.samefile(user_config_dir,
                                                    xdg_config_dir)):
            config_dirs.append(user_config_dir)
    except OSError as e:
        print_message(e)
        pass
    config_dirs.append('/etc/xdg')
    for config_dir in config_dirs:
        config_dir = config_dir + '/bl-exit'
        if os.path.isdir(config_dir):
            maybe_config_file = config_dir + '/bl-exitrc'
            if os.path.isfile(maybe_config_file):
                config_file = maybe_config_file
                break

    return config_file

def get_config_theme_entry(section, item, cp):
    """Get 'theme' entry from [theme] section.
    :param
      section: String, config section
      item:    String, config item
      cp:      ConfigParser, instance
    :out
      string or None"""
    if (cp.has_section(section)):
        try:
            _item = cp.get(section, item)
        except ConfigParser.NoOptionError:
            _item = None
        return _item
    else:
        return None

def main():
    '''
    The script works both in a graphical and a non-graphical environment.

    In a graphical environment, the BlExitWindow instance is only shown when
    the script is launched without arguments. The user selects the action she
    wants by clicking the right button.

    WHen  the script is launched In a non-graphical environment the requested
    action should be one of the accepted arguments and the action is executed
    without asking for confirmation - as if the script was launched from the
    command line.

    In a non-graphical environment, one of the accepted actions must be
    specified as an argument.
    '''
    if display and len(sys.argv[1:]) == 0:
        try:
            # import ConfigParser
            cp = ConfigParser.RawConfigParser()
            config_file = get_config_file()
            if config_file:
                cp.read(config_file)
                _theme = get_config_theme_entry('theme', 'theme', cp)
                if (_theme is not None and
                    _theme != 'classic'):
                    blexit = BlexitMerlin(cp, config_file)
                else:
                    blexit = Blexit(cp, config_file)
            else:
                blexit = Blexit(cp, config_file)
        except ConfigParser.ParsingError as e:
            print_message(str(e))
            return 1
        except ConfigParser.NoOptionError as e:
            print_message(str(e))
            blexit = Blexit(cp, config_file)
    else:
        blexit = BlexitBase()

    return blexit.main()

if __name__ == "__main__":
    sys.exit(main())
