#!/usr/bin/python2
# -*- coding: utf-8 -*-
import os.path
import collections
import argparse
import subprocess
import urllib
import logging
import warnings
import math
import ConfigParser

import dbus.mainloop.glib
import dbus.service

import gi
gi.require_version('Gtk', '3.0')
from gi.repository import GLib, Gtk, Gdk, GdkPixbuf, Pango
import cairo

CONFIG_PATH = os.path.join(os.getenv('XDG_CONFIG_HOME', os.path.expanduser('~/.config')), 'notipyrc')
HORIZONTAL, VERTICAL = range(2)

def simple_layout(windows, arrangement, x_offset, y_offset, gap):
    x, y = x_offset, y_offset
    sw, sh = Gdk.Screen.width(), Gdk.Screen.height()

    if x_offset < 0:
        wb, wa, x_gap = 1, 0, -gap
    else:
        wb, wa, x_gap = 0, 1, gap

    if y_offset < 0:
        hb, ha, y_gap = 1, 0, -gap
    else:
        hb, ha, y_gap = 0, 1, gap

    for win in windows.itervalues():
        ww, wh = win.get_size()
        if x < 0: x = sw + x + 1
        if y < 0: y = sh + y + 1

        x -= wb * ww
        y -= hb * wh

        win.move(x, y)
        win.show()

        if arrangement == HORIZONTAL:
            x = x + ww * wa + x_gap
            y = y_offset
        else:
            y = y + wh * ha + y_gap
            x = x_offset


class NotificationDaemon(dbus.service.Object):
    """
    Implements the gnome Desktop Notification Specification [1] to display
    popup information.

    [1] http://developer.gnome.org/notification-spec/
    """

    def __init__(self, object_path):
        bus_name = dbus.service.BusName(
            "org.freedesktop.Notifications", dbus.SessionBus())
        dbus.service.Object.__init__(self, bus_name, object_path)

        self.last_id = 0
        self.windows = collections.OrderedDict()
        self.close_events = {}
        self.urgency_options = {}

    def update_layout(self):
        simple_layout(self.windows, self.arrangement, self.x_offset, self.y_offset, self.gap)

    def create_window(self, urgency, summary, body, icon=None):

        win = Gtk.Window(type=Gtk.WindowType.POPUP)
        win.set_decorated(False)
        win.set_app_paintable(True)
        screen = win.get_screen()
        rgba = screen.get_rgba_visual()
        if rgba != None and screen.is_composited():
            win.set_visual(rgba)

        frame = Gtk.Frame()
        frame.set_border_width(self.padding);
        frame.set_shadow_type(Gtk.ShadowType.NONE)
        win.add(frame)

        hBox = Gtk.HBox()
        frame.add(hBox)

        logging.debug("type of icon: {}".format(str(type(icon))))

        iconWidget = None

        if not icon is None:
            if isinstance(icon, unicode):
                icon_path = os.path.expanduser(urllib.url2pathname(icon))
                if os.path.isfile(icon_path):
                    iconWidget = Gtk.Image()
                    iconWidget.set_from_file(icon_path)
                else:
                    # Note:
                    # See output of following script for available names:
                    # from gi.repository import Gtk
                    # print("\n".join(
                    #     Gtk.IconTheme.get_default().list_icons(None)))
                    theme = Gtk.IconTheme.get_default()
                    if theme.has_icon(icon):
                        iconWidget = Gtk.Image()
                        iconWidget.set_from_icon_name(icon, Gtk.IconSize.DND)
                    else:
                        warnings.warn(
                            "\"{}\" seems to be neither a valid icon file nor "
                            "a name in a freedesktop.org-compliant icon theme "
                            "(or your theme doesn't have that name). Ignoring."
                            .format(icon))

            else:
                # For image-data and icon_data, image should look like this:
                #
                # dbus.Struct(
                #   (dbus.Int32,                   # width
                #    dbus.Int32,                   # height
                #    dbus.Int32,                   # rowstride
                #    dbus.Boolean,                 # has alpha
                #    dbus.Int32,                   # bits per sample
                #    dbus.Int32,                   # channels
                #    dbus.Array([dbus.Byte, ...])) # image data
                # )

                # data, colorspace, has_alpha, bits_per_sample, width, height,
                # rowstride, destroy_fn, destroy_fn_data
                # FIXME: Do I need to free the image via a function callback?
                pixbuf = GdkPixbuf.Pixbuf.new_from_data(
                    bytearray(icon[6]), GdkPixbuf.Colorspace.RGB, icon[3],
                    icon[4], icon[0], icon[1], icon[2],
                    lambda x, y: None, None)

                iconWidget = Gtk.Image()
                iconWidget.set_from_pixbuf(pixbuf)

        if not iconWidget is None:
            hBox.pack_start(iconWidget, False, False, 0)

        vBox = Gtk.VBox(homogeneous=False)
        hBox.pack_start(vBox, False, False, 0)

        summaryLabel = Gtk.Label(xalign=0)
        summaryLabel.set_markup(self.urgency_options[urgency]['summary_format'].decode('utf-8').format(summary))
        vBox.pack_start(summaryLabel, True, True, 0)

        if body:
            bodyLabel = Gtk.Label(xalign=0)
            if len(body) > 30:
                bodyLabel.props.wrap = True

            try:
                # Parameters: markup_text, length, accel_marker
                # Return: (success, attr_list, text, accel_char)
                body = self.urgency_options[urgency]['body_format'].decode('utf-8').format(body)
                parse_result = Pango.parse_markup(body, -1, u"\x00")
                bodyLabel.set_text(parse_result[2])
                bodyLabel.set_attributes(parse_result[1])
            except GLib.GError:
                logging.exception("Invalid pango markup. Fix your application.")
                bodyLabel.set_text(body)
            vBox.pack_start(bodyLabel, True, True, 0)

        # The window's size has default values before showing it.
        win._urgency = urgency
        win.connect("draw", self.area_draw)

        frame.show_all()
        frame.realize()
        win.realize()

        req = frame.size_request()
        if req.width * 1.0 / req.height < 4:
            frame.set_size_request(int(math.sqrt(4 * req.width * req.height)), -1)

        req = win.size_request()
        win.resize(req.width, req.height)

        return win

    def area_draw(self, widget, cr):
        bg = self.urgency_options[widget._urgency]['background']
        alpha = self.urgency_options[widget._urgency]['background_alpha']

        cr.set_source_rgba(bg.red / 65535.0, bg.green / 65535.0, bg.blue / 65535.0, alpha)
        cr.set_operator(cairo.OPERATOR_SOURCE)
        cr.paint()
        cr.set_operator(cairo.OPERATOR_OVER)

    def notification_expired(self, id):
        """
        Callback called when a notification expired.

        @param id: the ID of the notification.
        @returns: False
        """
        self.close_notification(id, 1)
        return False  # Don't repeat timeout

    def window_clicked(self, widget, event, id):
        self.close_notification(id, 2)

    def remove_close_event(self, id):
        """
        Removes the close event belonging to the notification with ID id.

        @param id: the ID of the notification whose close event is to be
                   removed.
        @return: True if a close event was removed, False otherwise.
        """
        if id not in self.close_events:
            return False

        closeEvent = self.close_events.pop(id)
        GLib.source_remove(closeEvent)
        return True

    def remove_window(self, id, removeFromDict=True):
        """
        Removes the window belonging to the notification with ID id.

        @param id: the ID of the notification whose window is to be removed.
        @param removeFromDict: if True, id will be erased from self.windows.
        @return: True if a window was removed, False otherwise.
        """
        if id not in self.windows:
            return False

        win = self.windows[id]
        win.hide()
        win.destroy()

        if removeFromDict:
            del self.windows[id]

        return True

    def close_notification(self, id, reason):
        """
        Closes a notification and emits NotificationClosed if the notification
        exists.

        @param id: the ID of the notification.
        @param reason: the reason for closing the notification.
        @returns: True if a notification with this id existed, False otherwise.
        """
        self.remove_close_event(id)

        if self.remove_window(id):
            self.update_layout()
            self.NotificationClosed(id, reason)
            return True
        else:
            warnings.warn("Attempt to close non-existent notification {}"
                .format(id))
            return False

    @dbus.service.method(
        dbus_interface="org.freedesktop.Notifications",
        in_signature="",
        out_signature="as")
    def GetCapabilities(self):
        """
        Get the capabilities of this notification daemon implementation.
        @returns: An array of strings
        """
        # Available capabilities:
        # action-icons actions body body-hyperlinks body-images body-markup
        # icon-multi icon-static persistence sound
        return ["body", "body-markup", "persistence", "icon-static"]

    @dbus.service.method(
        dbus_interface="org.freedesktop.Notifications",
        in_signature="susssava{sv}i",
        out_signature="u")
    def Notify(
        self, app_name, replaces_id, app_icon, summary,
        body, actions, hints, expire_timeout):
        """
        @param app_name: string
        @param replaces_id: unsigned int
        @param app_icon: string
        @param summary: string
        @param body: string
        @param actions: array (even: id (int), odd: localized string)
        @param hints: dict
        @param expire_timeout: int

        @returns: unsigned int
        """

        notificationID = 0

        if 0 != replaces_id:
            # We can't use close_notification here because
            # a) the NotificationClosed signal must not be emitted
            # b) we must not remove replaces_id from windows or the order of
            #    the values in the dict would be changed
            # c) that would cause update_layout to be called twice
            self.remove_close_event(replaces_id)
            self.remove_window(replaces_id, False)
            notificationID = replaces_id
        else:
            self.last_id += 1
            notificationID = self.last_id

        logging.debug("summary: \"{}\", body: \"{}\"".format(
            unicode(summary).encode("ascii", errors="backslashreplace"),
            unicode(body).encode("ascii", errors="backslashreplace")))
        logging.debug("Notification ID: {}".format(notificationID))

        try:
            # Priorities for icon sources:
            #
            # 1. image-data: hint. raw image data structure of signature
            #                (iiibiiay)
            # 2. image-path: hint. either an URI (file://...) or a name in a
            #                freedesktop.org-compliant icon theme
            # 3. app_icon:   parameter. same as image-path
            # 4. icon_data:  hint. same as image-data

            image = None

            if "image-data" in hints:
                image = hints["image-data"]
            elif "image-path" in hints:
                image = hints["image-path"]
            elif app_icon != "":
                image = app_icon
            elif "icon_data" in hints:
                image = hints["icon_data"]

            urgency = hints.get('urgency', 1)

            win = self.create_window(urgency, summary, body, image)
            win.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
            win.connect(
                "button-press-event", self.window_clicked, notificationID)
            self.windows[notificationID] = win
            self.update_layout()

            if self.canberra:
	            subprocess.Popen(['canberra-gtk-play', '--id=message-new-instant'])

            if 0 != expire_timeout and urgency != 2:
                timeout = (self.timeout if expire_timeout < 0 else expire_timeout) / 1000
                logging.debug("Will close notification {} after {} seconds."
                    .format(notificationID, timeout))

                self.close_events[notificationID] = \
                    GLib.timeout_add_seconds(
                        timeout,
                        self.notification_expired,
                        notificationID)

        except Exception:
            logging.exception("Exception occured during window creation.")

        return notificationID

    @dbus.service.method(
        dbus_interface="org.freedesktop.Notifications",
        in_signature="u",
        out_signature="")
    def CloseNotification(self, id):
        """
        NotificationClosed signal or empty D-BUS error
        @param id: unsigned int
        """
        if not self.close_notification(id, 3):
            # Don't know what sending back an empty D-BUS error message is
            # supposed to mean...
            pass

    @dbus.service.method(
        dbus_interface="org.freedesktop.Notifications",
        in_signature="",
        out_signature="ssss")
    def GetServerInformation(self):
        """
        @returns: a tuple containing the server name, the vendor name, the
                  server version and the supported protocol version.
        """
        return ("Notifications", "freedesktop.org", "0.1", "0.7.1")

    # Signals

    @dbus.service.signal(
        dbus_interface="org.freedesktop.Notifications",
        signature="uu")
    def NotificationClosed(self, id, reason):
        """
        reason is one of:
        1 - The notification expired.
        2 - The notification was dismissed by the user.
        3 - The notification was closed by a call to CloseNotification.
        4 - Undefined/reserved reasons.

        @param id: unsigned int
        @param reason: unsigned int
        """
        logging.debug("Successfully closed notification {}. Reason: {}"
            .format(id, reason))

    @dbus.service.signal(
        dbus_interface="org.freedesktop.Notifications",
        signature="us")
    def ActionInvoked(self, id, action_key):
        """
        @param id: unsigned int
        @param action_key: string
        """
        pass


def create_argument_parser():
    parser = argparse.ArgumentParser(
        description="A notification server implementing the specification from"
                    " http://developer.gnome.org/notification-spec/.")

    parser.add_argument(
        "-l", "--loglevel",
        dest="loglevel",
        type=lambda value: value.upper(),
        choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
        help="set the logging level")

    parser.add_argument(
        "-f", "--config",
        dest="config",
        default=CONFIG_PATH,
        help="path to config")

    return parser


def main():
    parser = create_argument_parser()
    parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter
    args = parser.parse_args()

    cfg = ConfigParser.ConfigParser({
        'canberra': '0',
        'timeout': '5000',
        'loglevel': 'WARNING',

        'x_offset': '-1',
        'y_offset': '22',
        'gap': '3',
        'padding': '10',
        'arrangement': '1',
        'background': '#222',
        'background_alpha': '1.0',
    })
    cfg.add_section('Global')
    cfg.add_section('Layout')
    cfg.add_section('Low')
    cfg.add_section('Normal')
    cfg.add_section('Critical')

    cfg.read(args.config)

    logging.basicConfig(level=getattr(logging, args.loglevel or cfg.get('Global', 'loglevel')))

    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    daemon = NotificationDaemon("/org/freedesktop/Notifications")

    daemon.canberra = cfg.getboolean('Global', 'canberra')
    daemon.timeout = cfg.getint('Global', 'timeout')

    daemon.x_offset = cfg.getint('Layout', 'x_offset')
    daemon.y_offset = cfg.getint('Layout', 'y_offset')
    daemon.gap = cfg.getint('Layout', 'gap')
    daemon.padding = cfg.getint('Layout', 'padding')
    daemon.arrangement = cfg.getint('Layout', 'arrangement')

    default_opts = {
        'background': '#222',
        'background_alpha': '1.0',
        'summary_format': '<span color="white" weight="bold">{}</span>',
        'body_format': '<span color="white">{}</span>'
    }
    for urgency, section in enumerate(('Low', 'Normal', 'Critical')):
        opts = daemon.urgency_options[urgency] = {}

        if urgency == 2:
            default_opts['background'] = '#420041'

        opts['background'] = Gdk.color_parse(cfg.get(section, 'background', vars=default_opts))
        opts['background_alpha'] = float(cfg.get(section, 'background_alpha', vars=default_opts))
        opts['summary_format'] = cfg.get(section, 'summary_format', vars=default_opts)
        opts['body_format'] = cfg.get(section, 'body_format', vars=default_opts)

    loop = GLib.MainLoop()
    try:
        loop.run()
    except KeyboardInterrupt:
        logging.info("Exiting.")


if __name__ == '__main__':
    main()
