/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */

const DBus = imports.dbus;
const GLib = imports.gi.GLib;
const Lang = imports.lang;
const Shell = imports.gi.Shell;
const Mainloop = imports.mainloop;
const St = imports.gi.St;
const Gettext = imports.gettext.domain('gnome-shell');
const _ = Gettext.gettext;

const Config = imports.misc.config;
const Main = imports.ui.main;
const MessageTray = imports.ui.messageTray;
const Params = imports.misc.params;
const Util = imports.misc.util;

let nextNotificationId = 1;

// Should really be defined in dbus.js
const BusIface = {
    name: 'org.freedesktop.DBus',
    methods: [{ name: 'GetConnectionUnixProcessID',
                inSignature: 's',
                outSignature: 'i' }]
};

const Bus = function () {
    this._init();
};

Bus.prototype = {
     _init: function() {
         DBus.session.proxifyObject(this, 'org.freedesktop.DBus', '/org/freedesktop/DBus');
     }
};

DBus.proxifyPrototype(Bus.prototype, BusIface);

const NotificationDaemonIface = {
    name: 'org.freedesktop.Notifications',
    methods: [{ name: 'Notify',
                inSignature: 'susssasa{sv}i',
                outSignature: 'u'
              },
              { name: 'CloseNotification',
                inSignature: 'u',
                outSignature: ''
              },
              { name: 'GetCapabilities',
                inSignature: '',
                outSignature: 'as'
              },
              { name: 'GetServerInformation',
                inSignature: '',
                outSignature: 'ssss'
              }],
    signals: [{ name: 'NotificationClosed',
                inSignature: 'uu' },
              { name: 'ActionInvoked',
                inSignature: 'us' }]
};

const NotificationClosedReason = {
    EXPIRED: 1,
    DISMISSED: 2,
    APP_CLOSED: 3,
    UNDEFINED: 4
};

const Urgency = {
    LOW: 0,
    NORMAL: 1,
    CRITICAL: 2
};

const rewriteRules = {
    'XChat': [
        { pattern:     /^XChat: Private message from: (\S*) \(.*\)$/,
          replacement: '<$1>' },
        { pattern:     /^XChat: New public message from: (\S*) \((.*)\)$/,
          replacement: '$2 <$1>' },
        { pattern:     /^XChat: Highlighted message from: (\S*) \((.*)\)$/,
          replacement: '$2 <$1>' }
    ]
};

function NotificationDaemon() {
    this._init();
}

NotificationDaemon.prototype = {
    _init: function() {
        DBus.session.exportObject('/org/freedesktop/Notifications', this);

        this._everAcquiredName = false;
        DBus.session.acquire_name('org.freedesktop.Notifications',
                                  // We pass MANY_INSTANCES so that if
                                  // notification-daemon is running, we'll
                                  // get queued behind it and then get the
                                  // name after killing it below
                                  DBus.MANY_INSTANCES,
                                  Lang.bind(this, this._acquiredName),
                                  Lang.bind(this, this._lostName));

        this._sources = {};
        this._senderToPid = {};
        this._notifications = {};
        this._busProxy = new Bus();

        Main.statusIconDispatcher.connect('message-icon-added', Lang.bind(this, this._onTrayIconAdded));
        Main.statusIconDispatcher.connect('message-icon-removed', Lang.bind(this, this._onTrayIconRemoved));

        Shell.WindowTracker.get_default().connect('notify::focus-app',
            Lang.bind(this, this._onFocusAppChanged));
        Main.overview.connect('hidden',
            Lang.bind(this, this._onFocusAppChanged));
    },

    _acquiredName: function() {
        this._everAcquiredName = true;
    },

    _lostName: function() {
        if (this._everAcquiredName)
            log('Lost name org.freedesktop.Notifications!');
        else if (GLib.getenv('GNOME_SHELL_NO_REPLACE'))
            log('Failed to acquire org.freedesktop.Notifications');
        else {
            log('Failed to acquire org.freedesktop.Notifications; trying again');
            Util.killall('notification-daemon');
            Util.killall('notify-osd');
        }
    },

    _iconForNotificationData: function(icon, hints, size) {
        let textureCache = St.TextureCache.get_default();

        if (icon) {
            if (icon.substr(0, 7) == 'file://')
                return textureCache.load_uri_async(icon, size, size);
            else if (icon[0] == '/') {
                let uri = GLib.filename_to_uri(icon, null);
                return textureCache.load_uri_async(uri, size, size);
            } else
                return new St.Icon({ icon_name: icon,
                                     icon_type: St.IconType.FULLCOLOR,
                                     icon_size: size });
        } else if (hints.icon_data) {
            let [width, height, rowStride, hasAlpha,
                 bitsPerSample, nChannels, data] = hints.icon_data;
            return textureCache.load_from_raw(data, data.length, hasAlpha,
                                              width, height, rowStride, size);
        } else {
            let stockIcon;
            switch (hints.urgency) {
                case Urgency.LOW:
                case Urgency.NORMAL:
                    stockIcon = 'gtk-dialog-info';
                    break;
                case Urgency.CRITICAL:
                    stockIcon = 'gtk-dialog-error';
                    break;
            }
            return new St.Icon({ icon_name: stockIcon,
                                 icon_type: St.IconType.FULLCOLOR,
                                 icon_size: size });
        }
    },

    // Returns the source associated with ndata.notification if it is set.
    // Otherwise, returns the source associated with the pid if one is
    // stored in this._sources and the notification is not transient.
    // Otherwise, creates a new source as long as pid is provided.
    //
    // Either a pid or ndata.notification is needed to retrieve or
    // create a source.
    _getSource: function(title, pid, ndata) {
        if (!pid && !(ndata && ndata.notification))
            return null;

        // We use notification's source for the notifications we still have
        // around that are getting replaced because we don't keep sources
        // for transient notifications in this._sources, but we still want
        // the notification associated with them to get replaced correctly.
        if (ndata && ndata.notification)
            return ndata.notification.source;

        let isForTransientNotification = (ndata && ndata.hints['transient'] == true);

        // We don't want to override a persistent notification
        // with a transient one from the same sender, so we
        // always create a new source object for new transient notifications
        // and never add it to this._sources .
        if (!isForTransientNotification && this._sources[pid])
            return this._sources[pid];

        let source = new Source(title, pid);
        source.setTransient(isForTransientNotification);

        if (!isForTransientNotification) {
            this._sources[pid] = source;
            source.connect('destroy', Lang.bind(this,
                function() {
                    delete this._sources[pid];
                }));
        }

        Main.messageTray.add(source);
        return source;
    },

    Notify: function(appName, replacesId, icon, summary, body,
                     actions, hints, timeout) {
        let id;

        // Filter out notifications from Empathy, since we
        // handle that information from telepathyClient.js
        if (appName == 'Empathy') {
            // Ignore replacesId since we already sent back a
            // NotificationClosed for that id.
            id = nextNotificationId++;
            Mainloop.idle_add(Lang.bind(this,
                                        function () {
                                            this._emitNotificationClosed(id, NotificationClosedReason.DISMISSED);
                                        }));
            return id;
        }

        let rewrites = rewriteRules[appName];
        if (rewrites) {
            for (let i = 0; i < rewrites.length; i++) {
                let rule = rewrites[i];
                if (summary.search(rule.pattern) != -1)
                    summary = summary.replace(rule.pattern, rule.replacement);
            }
        }

        hints = Params.parse(hints, { urgency: Urgency.NORMAL }, true);

        let ndata = { appName: appName,
                      icon: icon,
                      summary: summary,
                      body: body,
                      actions: actions,
                      hints: hints,
                      timeout: timeout };
        if (replacesId != 0 && this._notifications[replacesId]) {
            ndata.id = id = replacesId;
            ndata.notification = this._notifications[replacesId].notification;
        } else {
            replacesId = 0;
            ndata.id = id = nextNotificationId++;
        }
        this._notifications[id] = ndata;

        let sender = DBus.getCurrentMessageContext().sender;
        let pid = this._senderToPid[sender];

        let source = this._getSource(appName, pid, ndata);

        if (source) {
            this._notifyForSource(source, ndata);
            return id;
        }

        if (replacesId) {
            // There's already a pending call to GetConnectionUnixProcessID,
            // which will see the new notification data when it finishes,
            // so we don't have to do anything.
            return id;
        }

        this._busProxy.GetConnectionUnixProcessIDRemote(sender, Lang.bind(this,
            function (pid, ex) {
                // The app may have updated or removed the notification
                ndata = this._notifications[id];
                if (!ndata)
                    return;

                source = this._getSource(appName, pid, ndata);

                // We only store sender-pid entries for persistent sources.
                // Removing the entries once the source is destroyed
                // would result in the entries associated with transient
                // sources removed once the notification is shown anyway.
                // However, keeping these pairs would mean that we would
                // possibly remove an entry associated with a persistent
                // source when a transient source for the same sender is
                // distroyed.
                if (!source.isTransient) {
                    this._senderToPid[sender] = pid;
                    source.connect('destroy', Lang.bind(this,
                        function() {
                            delete this._senderToPid[sender];
                        }));
                }
                this._notifyForSource(source, ndata);
            }));

        return id;
    },

    _notifyForSource: function(source, ndata) {
        let [id, icon, summary, body, actions, hints, notification] =
            [ndata.id, ndata.icon, ndata.summary, ndata.body,
             ndata.actions, ndata.hints, ndata.notification];

        let iconActor = this._iconForNotificationData(icon, hints, source.ICON_SIZE);

        if (notification == null) {
            notification = new MessageTray.Notification(source, summary, body,
                                                        { icon: iconActor,
                                                          bannerMarkup: true });
            ndata.notification = notification;
            notification.connect('clicked', Lang.bind(this,
                function(n) {
                    this._emitNotificationClosed(id, NotificationClosedReason.DISMISSED);
                }));
            notification.connect('destroy', Lang.bind(this,
                function(n) {
                    delete this._notifications[id];
                }));
            notification.connect('action-invoked', Lang.bind(this,
                function(n, actionId) {
                    this._emitActionInvoked(id, actionId);
                }));
        } else {
            notification.update(summary, body, { icon: iconActor,
                                                 bannerMarkup: true,
                                                 clear: true });
        }

        if (actions.length) {
            notification.setUseActionIcons(hints['action-icons'] == true);
            for (let i = 0; i < actions.length - 1; i += 2)
                notification.addButton(actions[i], actions[i + 1]);
        }
        switch (hints.urgency) {
            case Urgency.LOW:
                notification.setUrgency(MessageTray.Urgency.LOW);
                break;
            case Urgency.NORMAL:
                notification.setUrgency(MessageTray.Urgency.NORMAL);
                break;
            case Urgency.CRITICAL:
                notification.setUrgency(MessageTray.Urgency.CRITICAL);
                break;
        }
        notification.setResident(hints.resident == true);
        // 'transient' is a reserved keyword in JS, so we have to retrieve the value
        // of the 'transient' hint with hints['transient'] rather than hints.transient
        notification.setTransient(hints['transient'] == true);

        let sourceIconActor = source.useNotificationIcon ? this._iconForNotificationData(icon, hints, source.ICON_SIZE) : null;
        source.notify(notification, sourceIconActor);
    },

    CloseNotification: function(id) {
        let ndata = this._notifications[id];
        if (ndata) {
            if (ndata.notification)
                ndata.notification.destroy();
            delete this._notifications[id];
        }
        this._emitNotificationClosed(id, NotificationClosedReason.APP_CLOSED);
    },

    GetCapabilities: function() {
        return [
            'actions',
            'action-icons',
            'body',
            // 'body-hyperlinks',
            // 'body-images',
            'body-markup',
            // 'icon-multi',
            'icon-static',
            'persistence',
            // 'sound',
        ];
    },

    GetServerInformation: function() {
        return [
            Config.PACKAGE_NAME,
            'GNOME',
            Config.PACKAGE_VERSION,
            '1.2'
        ];
    },

    _onFocusAppChanged: function() {
        let tracker = Shell.WindowTracker.get_default();
        if (!tracker.focus_app)
            return;

        for (let id in this._sources) {
            let source = this._sources[id];
            if (source.app == tracker.focus_app) {
                if (source.notification && !source.notification.resident)
                    source.notification.destroy();
                return;
            }
        }
    },

    _emitNotificationClosed: function(id, reason) {
        DBus.session.emit_signal('/org/freedesktop/Notifications',
                                 'org.freedesktop.Notifications',
                                 'NotificationClosed', 'uu',
                                 [id, reason]);
    },

    _emitActionInvoked: function(id, action) {
        DBus.session.emit_signal('/org/freedesktop/Notifications',
                                 'org.freedesktop.Notifications',
                                 'ActionInvoked', 'us',
                                 [id, action]);
    },

    _onTrayIconAdded: function(o, icon) {
        let source = this._getSource(icon.title || icon.wm_class || _("Unknown"), icon.pid, null);
        source.setTrayIcon(icon);
    },

    _onTrayIconRemoved: function(o, icon) {
        let source = this._sources[icon.pid];
        if (source)
            source.destroy();
    }
};

DBus.conformExport(NotificationDaemon.prototype, NotificationDaemonIface);

function Source(title, pid) {
    this._init(title, pid);
}

Source.prototype = {
    __proto__:  MessageTray.Source.prototype,

    _init: function(title, pid) {
        MessageTray.Source.prototype._init.call(this, title);

        this._pid = pid;
        this._setApp();
        if (this.app)
            this.title = this.app.get_name();
        else
            this.useNotificationIcon = true;
        this._isTrayIcon = false;
    },

    notify: function(notification, icon) {
        if (!this.app)
            this._setApp();
        if (!this.app && icon)
            this._setSummaryIcon(icon);
        MessageTray.Source.prototype.notify.call(this, notification);
    },

    _setApp: function() {
        if (this.app)
            return;

        this.app = Shell.WindowTracker.get_default().get_app_from_pid(this._pid);
        if (!this.app)
            return;

        // Only override the icon if we were previously using
        // notification-based icons (ie, not a trayicon) or if it was unset before
        if (!this._isTrayIcon) {
            this.useNotificationIcon = false;
            this._setSummaryIcon(this.app.create_icon_texture (this.ICON_SIZE));
        }
    },

    setTrayIcon: function(icon) {
        this._setSummaryIcon(icon);
        this.useNotificationIcon = false;
        this._isTrayIcon = true;
    },

    _notificationClicked: function(notification) {
        this.openApp();
    },

    _notificationRemoved: function() {
        if (!this._isTrayIcon)
            this.destroy();
    },

    openApp: function() {
        if (this.app == null)
            return;

        let windows = this.app.get_windows();
        if (windows.length > 0) {
            let mostRecentWindow = windows[0];
            Main.activateWindow(mostRecentWindow);
        }
    }
};