/*
 * Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *  
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *  
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 
 * DEALINGS IN THE SOFTWARE.
 * 
 */

/* globals Set: true */

(function () {
    "use strict";

    var EventEmitter = require("events").EventEmitter,
        Q = require("q"),
        photoshop = require("./photoshop"),
        convert = require("./convert"),
        util = require("util"),
        semver = require("semver"),
        xpm = require("./xpm"),
        Server = require("./rpc/Server"),
        packageConfig = require("../package.json");

    var _instanceCount = 0;

    var MENU_STATE_KEY_PREFIX = "GENERATOR-MENU-",
        PHOTOSHOP_EVENT_PREFIX = "PHOTOSHOP-EVENT-",
        PLUGIN_KEY_PREFIX = "PLUGIN-";

    var PLUGIN_INCOMPATIBLE_MESSAGE = "$$$/Generator/NotCompatibleString";

    // Photoshop versions may not contain the patch version (example 19.0)
    var PARTIAL_VERSION_REGEX = /^\d+\.\d+$/;

    /**
     * Set of plugin names that should be blocked from loading
     *
     * @type {Set}
     */
    var PLUGIN_BLACKLIST = new Set(["adobe-preview-generator-plugin"]);

    // Some commands result in multiple response messages. After the first response
    // message is received, if there's a gap longer than this in responses,
    // we assume there was an error.
    var MULTI_MESSAGE_TIMEOUT = 5000;

    function escapePluginId(pluginId) {
        if (!pluginId) { return pluginId; }
        return pluginId.replace(/[^a-zA-Z0-9]/g, function (char) {
            return "_" + char.charCodeAt(0) + "_";
        });
    }

    function unescapePluginId(pluginId) {
        if (!pluginId) { return pluginId; }
        return pluginId.replace(/_(\d+)_/g, function (match, charCode) {
            return String.fromCharCode(charCode);
        });
    }

    /**
     * @constructor
     */
    function Generator(loggerManager) {
        if (!(this instanceof Generator)) {
            return new Generator(loggerManager);
        }

        this._loggerManager = loggerManager;
        this._logger = loggerManager.createLogger("core");

        // TODO: declare these as prototype properties and document types
        this._plugins = {};
        this._photoshop = null;
        this._instanceID = _instanceCount++;
        this._messageDeferreds = [];
        this._eventSubscriptions = {};
        this._menuState = {};
        this._paths = {};
        this._options = {};
        this._sendJSXCache = {};
    }
    util.inherits(Generator, EventEmitter);


    function createGenerator(loggerManager) {
        return new Generator(loggerManager);
    }

    Object.defineProperty(Generator.prototype, "version", {enumerable: true, value: packageConfig.version});

    Generator.prototype.start = function (options) {
        var self = this;

        options = options || {};
        self._options = options;
        self._config = options.config || {};

        this._logger.info("Launching with config:\n%s", JSON.stringify(self._config, null, "  "));
        if (options.photoshopVersion) {
            this._logger.info("provided PS information:\n version %s\n path%s\n",
                options.photoshopVersion, options.photoshopPath);
        }

        function connectToPhotoshop() {
            var connectionDeferred = Q.defer();
            self._photoshop = photoshop.createClient(options, undefined, self._logger);
            self._photoshop.once("connect", function () {
                connectionDeferred.resolve(self);
            });

            self._photoshop.on("close", function () {
                self._logger.info("Photoshop connection closed");
                self.emit("close");
            });

            self._photoshop.on("error", function (err) {
                self._logger.warn("Photoshop error", err);
                // If the error does refers to a specific command we ran, reject the corresponding deferred
                if (err && self._messageDeferreds.hasOwnProperty(err.id)) {
                    self._messageDeferreds[err.id].reject(err.body);
                }
                // TODO: Otherwise, gracefully shut down?
            });

            self._photoshop.on("communicationsError", function (err, rawMessage) {
                self._logger.warn("photoshop communications error: %j", {error: err, rawMessage: rawMessage});
            });

            self._photoshop.on("message", function (messageID, parsedValue) { // ,rawMessage)
                if (self._messageDeferreds[messageID]) {
                    self._messageDeferreds[messageID].notify({type: "javascript", value: parsedValue});
                }
            });

            self._photoshop.on("info", function (info) {
                self._logger.info("Photoshop info: %j", info);
            });

            self._photoshop.on("event", function (messageID, eventName, parsedValue) { // , rawMessage)
                self.emitPhotoshopEvent(eventName, parsedValue);
            });

            self._photoshop.on("pixmap", function (messageID, messageBody) { // , rawMessage)
                if (self._messageDeferreds[messageID]) {
                    self._messageDeferreds[messageID].notify({type: "pixmap", value: messageBody});
                }
            });

            self._photoshop.on("iccProfile", function (messageID, messageBody) { // , rawMessage)
                if (self._messageDeferreds[messageID]) {
                    self._messageDeferreds[messageID].notify({type: "iccProfile", value: messageBody});
                }
            });

            return connectionDeferred.promise;
        }

        function setBinaryPaths() {
            var fs = require("fs"),
                resolve = require("path").resolve;

            return self.getPhotoshopExecutableLocation()
                .fail(function (err) {
                    var locError = new Error("Error retrieving Photoshop executable location");
                    locError.cause = err;
                    throw locError;
                })
                .then(function (psPath) {
                    self._paths.photoshop = psPath;
                    var convertPath = process.platform === "darwin" ?
                        resolve(psPath, "convert") :
                        resolve(psPath, "convert.exe");

                    var convertPromise = Q.nfcall(fs.stat, convertPath)
                        .then(function () {
                            self._paths.convert = convertPath;
                        })
                        .fail(function (err) {
                            var convertError = new Error("Error locating convert binary. Generator will not function.");
                            convertError.cause = err;
                            throw convertError;
                        });

                    var pngquantPath = process.platform === "darwin" ?
                        resolve(psPath, "pngquant") :
                        resolve(psPath, "pngquant.exe");

                    var pngquantPromise = Q.nfcall(fs.stat, pngquantPath)
                        .then(function () {
                            self._paths.pngquant = pngquantPath;
                        })
                        .fail(function (err) {
                            self._logger.warn("PNGQuant binary is missing. functionality will not be available.", err);
                            self._paths.pngquant = null;
                        });

                    var flitePath = process.platform === "darwin" ?
                        resolve(psPath, "flitetranscoder") :
                        resolve(psPath, "flitetranscoder.exe");

                    var flitePromise = Q.nfcall(fs.stat, flitePath)
                        .then(function () {
                            self._paths.flite = flitePath;
                        })
                        .fail(function (err) {
                            self._logger.warning("flitetranscoder binary is missing." +
                                            " flite functionality will not be available.",
                                err);
                            self._paths.flite = null;
                        });


                    return Q.all([convertPromise, pngquantPromise, flitePromise]);
                });
        }

        function confirmPhotoshopVersion() {
            return self.getPhotoshopVersion().then(function (version) {
                var requiredVersion = packageConfig["photoshop-version"];

                self._logger.info("Detected Photoshop version: %s", version);
                if (!semver.satisfies(version, requiredVersion)) {
                    var template = "Generator version %s requires Photoshop version %s",
                        message = util.format(template, packageConfig.version, requiredVersion);

                    throw new Error(message);
                }
            });
        }

        return connectToPhotoshop()
            .then(function () {
                var binaryPathsPromise = setBinaryPaths(),
                    photoshopVersionPromise = confirmPhotoshopVersion();

                return Q.all([binaryPathsPromise, photoshopVersionPromise]);
            })
            .then(function () {
                // Setup Headlights logging
                self._logHeadlights("Startup");
                self._logHeadlights("Version: " + packageConfig.version);
                self.onPhotoshopEvent("generatorMenuChanged", function (event) {
                    var menu = event.generatorMenuChanged;
                    if (menu && menu.name) {
                        self._logHeadlights("Menu selected: " + menu.name);
                    }
                });
            });
    };

    /**
     * Note: This is a private method. Call at your own risk, and follow instructions below.
     * Most users of the Generator API will want the public Generator.prototype.evaluateJSXString
     * method below.
     *
     * This method returns a deferred (not a promise). Every time a message comes in from Photoshop
     * pertaining to this call, a "progress" notification is issued on the deferred. When the caller of
     * this method is no longer interested in any more messages, it is the responsibility of the
     * caller to resolve the returned deferred. Doing that will cause the necessary cleanup
     * to happen internally (via a "finally" handler that this method installs on the deferred).
     *
     * The progress notification will be an object of the form:
     * { type : [string like "javascript" or "pixmap" or "iccProfile"],
     * value : [dependent on type -- Object if "javascript", Buffer if "pixmap" or "iccProfile"] }
     *
     * If the caller of this message never resolves/rejects the returned deferred, then we'll have a
     * memory leak on our hands.
     *
     * Optionally, the caller of this method can specify its own deferred object to use. If one is not
     * provided, then a new deferred will be constructed. In either case, the deferred that will receive
     * progress notifications is returned.
     *
     * The reason this method (and _sendJSXFile) exist is because some Generator-specific ExtendScript
     * returns multiple JS messages, and we need a way to handle that. However, all other general-purpose
     * ExtendScript will only return one (which is what the public API expects). As we
     * move Generator things out of ExtendScript (or add more stuff to Generator), the requirements
     * of this method (and _sendJSXFile) may change. That's why it's private.
     *
     * @private
     * @param {string} s JSX string to execute
     * @param {Deferred=} deferred
     * @param {boolean=} sharedEngineSafe Optional, if true allow PS to use a shared script engine
     * @return {Deferred}
     */
    Generator.prototype._sendJSXString = function (s, deferred, sharedEngineSafe) {
        var self = this,
            id = self._photoshop.sendCommand(s, sharedEngineSafe);

        deferred = deferred || Q.defer();

        self._messageDeferreds[id] = deferred;

        deferred.promise.finally(function () {
            delete self._messageDeferreds[id];
        });

        return deferred;
    };


    /**
     * Note: This is a private method. Call at your own risk, and follow instructions above for _sendJSXString.
     * Most users of the Generator API will want the public Generator.prototype.evaluateJSXFile
     * method below.
     *
     * See the comment for _sendJSXString above for details on how to use this if you really
     * need to use it.
     *
     * @private
     * @param {string} path
     * @param {object=} params
     * @param {boolean=} sharedEngineSafe
     * @return {Deferred}
     */
    Generator.prototype._sendJSXFile = function (path, params, sharedEngineSafe) {
        var self = this,
            deferred = Q.defer();

        var loadJSX = function (path) {
            var resolve = require("path").resolve,
                readFile = Q.denodeify(require("fs").readFile);

            var resolvedPath = resolve(__dirname, path);

            if (self._sendJSXCache.hasOwnProperty(resolvedPath) && self._sendJSXCache[resolvedPath] !== "") {
                return Q.fulfill(self._sendJSXCache[resolvedPath]);
            } else {
                return (
                    readFile(resolvedPath, {encoding: "utf8"})
                    .then(function (data) {
                        self._sendJSXCache[resolvedPath] = data;
                        return data;
                    })
                );
            }
        };

        var stringifyParams = function (params) {
            var paramsString = "null";
            if (params) {
                try {
                    paramsString = JSON.stringify(params);
                } catch (jsonError) {
                    return Q.reject(jsonError);
                }
            }
            return Q.fulfill(paramsString);
        };

        Q.spread([loadJSX(path), stringifyParams(params)], function (jsx, paramsString) {
            self._logger.debug("Sending JSX file with params", path, paramsString);
            var data = "var params = " + paramsString + ";\n" + jsx;
            self._sendJSXString(data, deferred, sharedEngineSafe);
        })
        .fail(function (err) {
            deferred.reject(err);
        });

        // Log a warning when any JSX file throws an exception
        // This is helpful for troubleshooting because it ties an error back to a specific JSX file
        deferred.promise.fail(function (err) {
            self._logger.warn("Error in JSX file with params", path, params, err);
        });

        return deferred;
    };

    /**
     * Evaluate a local JSX file with optional parameters inserted
     *
     * @param {string} path
     * @param {object=} params
     * @param {boolean=} sharedEngineSafe Optional, if true allow PS to use a shared script engine
     * @return {Promise}
     */
    Generator.prototype.evaluateJSXFile = function (path, params, sharedEngineSafe) {
        var self = this,
            deferred = self._sendJSXFile(path, params, sharedEngineSafe);

        deferred.promise.progress(function (message) {
            if (message.type === "javascript") {
                deferred.resolve(message.value);
            }
        });

        return deferred.promise;
    };

    /**
     * Wrapper for evaluateJSXFile, using Shared Engine Safe mode
     *
     * @param {string} path
     * @param {object=} params
     * @return {Promise}
     */
    Generator.prototype.evaluateJSXFileSharedSafe = function (path, params) {
        return this.evaluateJSXFile(path, params, true);
    };

    /**
     * Evaluate a JSX string
     *
     * @param {string} s
     * @param {boolean=} sharedEngineSafe Optional, if true allow PS to use a shared script engine
     * @return {Promise}
     */
    Generator.prototype.evaluateJSXString = function (s, sharedEngineSafe) {
        var self = this,
            deferred = self._sendJSXString(s, undefined, sharedEngineSafe);

        deferred.promise.progress(function (message) {
            if (message.type === "javascript") {
                deferred.resolve(message.value);
            }
        });

        return deferred.promise;
    };

    /**
     * Wrapper for evaluateJSXString, using Shared Engine Safe mode
     *
     * @param {string} s
     * @return {Promise}
     */
    Generator.prototype.evaluateJSXStringSharedSafe = function (s) {
        return this.evaluateJSXString(s, true);
    };

    /**
     * Simple window alert
     *
     * @param {string} message
     * @param {string} stringReplacements
     */
    Generator.prototype.alert = function (message, stringReplacements) {
        this.evaluateJSXFileSharedSafe("./jsx/alert.jsx", { message: message, replacements: stringReplacements });
    };

    /**
     * Copy specified string to system's clipboard.
     *
     * @param {!string} str The String
     */
    Generator.prototype.copyToClipboard = function (str) {
        this.evaluateJSXFileSharedSafe("./jsx/copyToClipboard.jsx", { clipboard: str });
    };

    /**
     * Returns a Promise that resolves to the full path to the location of the root
     * Photoshop install directory (where things like the third-party "Plug-ins"
     * directory live).
     *
     * On Mac this will look something like:
     *    /Applications/Adobe Photoshop CC
     *
     * On Windows this will look something like:
     *    C:\Program Files\Adobe\Adobe Photoshop CC (64 Bit)
     *
     * See also: Generator.prototype.getPhotoshopExecutableLocation
     */
    Generator.prototype.getPhotoshopPath = function () {
        if (!this._options.photoshopPath) {
            return this.evaluateJSXStringSharedSafe("File(app.path).fsName");
        } else {
            return Q.fcall(function () {
                return this._options.photoshopPath;
            }.bind(this));
        }
    };

    /**
     * Returns a Promise that resolves to the full path to the location of the Photoshop
     * executable (not including the name of the executable itself.) On Mac, this gives
     * a location *inside* the .app bundle.
     *
     * Important: Due to a bug in Photoshop (that likely won't be fixed), this function
     * will not work properly if there is a literal "%20" in the absolute path. Moreover,
     * PS as a whole may not work properly if there is a literal "%20" in its executable path
     *
     * On Mac this will look something like:
     *    /Applications/Adobe Photoshop CC/Adobe Photoshop CC.app/Contents/MacOS
     *
     * On Windows this will look something like:
     *    C:\Program Files\Adobe\Adobe Photoshop CC (64 Bit)
     *
     * See also: Generator.prototype.getPhotoshopPath
     */
    Generator.prototype.getPhotoshopExecutableLocation = function () {
        if (!this._options.photoshopBinaryPath) {
            return this.evaluateJSXFileSharedSafe("./jsx/getPhotoshopExecutableLocation.jsx", {});
        } else {
            return Q.fcall(function () {
                return this._options.photoshopBinaryPath;
            }.bind(this));
        }
    };

    Generator.prototype.getPhotoshopLocale = function () {
        return this.evaluateJSXStringSharedSafe("app.locale");
    };

    /**
     * Asynchronously get the Photoshop version number, e.g. "19.0.0".
     * Checks first if the options was supplied on the command line
     * Tolerates when Photoshop provides abbreviated versions with only major/minor portions,
     * but attempts to return with a zero patch level included
     *
     * @return {Promise.<string>} Resolves with the Photoshop version number.
     */
    Generator.prototype.getPhotoshopVersion = function () {
        // helper to append a patch version if photoshop doesn't include it (19.0 => 19.0.0)
        function cleanVersion(version) {
            return PARTIAL_VERSION_REGEX.test(version) ? version + ".0" : version;
        }

        if (this._options.photoshopVersion) {
            var cleanedVersion = cleanVersion(this._options.photoshopVersion);
            if (semver.valid(cleanedVersion)) {
                return Q(cleanedVersion);
            }
        }

        return this.evaluateJSXFileSharedSafe("./jsx/getPhotoshopVersion.jsx", {}).then(cleanVersion);
    };

    Generator.prototype.addMenuItem = function (name, displayName, enabled, checked) {
        var menuItems = [], m;

        // Store menu state
        this._menuState[MENU_STATE_KEY_PREFIX + name] = {
            name: name,
            displayName: displayName,
            enabled: enabled,
            checked: checked
        };

        // Rebuild the whole menu
        for (m in this._menuState) {
            if (m.indexOf(MENU_STATE_KEY_PREFIX) === 0) {
                menuItems.push(this._menuState[m]);
            }
        }
        return this.evaluateJSXFileSharedSafe("./jsx/buildMenu.jsx", {items : menuItems});
    };

    /**
     * Change the enabled and checked state of an existing menu (and optionally change
     * the display name). Returns a promise that resolves once the menu has been changed.
     *
     * @param {!string}  name        The identifier for the menu used when the menu was created
     * @param {!boolean} enabled     Whether the menu should be enabled
     * @param {!boolean} checked     Whether the menu should have a check mark
     * @param {?string}  displayName The new displayed menu text (remains unchanged if not specified)
     *
     * @return {Promise}             A promise that resolves once the menu has been changed
     */
    Generator.prototype.toggleMenu = function (name, enabled, checked, displayName) {
        var menu = this._menuState[MENU_STATE_KEY_PREFIX + name];
        if (menu) {
            // store the state
            menu.enabled = enabled;
            menu.checked = checked;

            // send the new state to photoshop
            var params = {name: name, enabled: enabled, checked: checked};
            if (typeof(displayName) === "string" && displayName !== "") {
                params.displayName = displayName;
            }

            return this.evaluateJSXFileSharedSafe("./jsx/toggleMenu.jsx", params);
        } else {
            var toggleFailedDeferred = Q.defer();
            toggleFailedDeferred.reject("no menu with ID " + name);
            return toggleFailedDeferred.promise;
        }
    };

    Generator.prototype.getMenuState = function (name) {
        var result = null,
            menu = this._menuState[MENU_STATE_KEY_PREFIX + name];

        if (menu) {
            result = {
                enabled: menu.enabled,
                checked: menu.checked
            };
        }
        return result;
    };

    /**
     * Get an array of all open document IDs.
     * Returns a promise that resolves to an array of integers.
     */
    Generator.prototype.getOpenDocumentIDs = function () {
        return this.evaluateJSXFileSharedSafe("./jsx/getOpenDocumentIDs.jsx", {}).then(function (ids) {
            if (typeof ids === "number") {
                return [ids];
            } else if (typeof ids === "string" && ids.length > 0) {
                return ids.split(":").map(function (id) { return parseInt(id, 10); });
            } else {
                return [];
            }
        });
    };

    /**
     * Get information about a document.
     * To find out about the current document, leave documentId empty.
     * @param {?integer} documentId Optional document ID
     * @param {?Object.<string, boolean>} flags Optional override of default flags for
     *   document info request. The optional flags and their default values are:
     *
     *   compInfo:             true
     *   imageInfo:            true
     *   layerInfo:            true
     *     Specifies which info to send (image-specific, layer-specific, comp-specific)
     *     If none of these is specified, all three default to true, otherwise it just
     *     returns the true values
     *   expandSmartObjects:   false
     *     recurse into smart object (placed) documents
     *   getTextStyles:        true
     *     get limited text/style info for text layers. Returned in the "text" property of
     *     layer info
     *   getFullTextStyles:    false
     *     get all text/style info for text layers. Returned in the "text" property of
     *     layer info, can be rather verbose
     *   selectedLayers:       false
     *     If true, only return details on the layers that the user has selected. If false,
     *     all layers are returned
     *   getCompLayerSettings: true
     *     If true, send actual layer settings in comps (not just the comp ids, useVisibility,
     *     usePosition, and useAppearance)
     *   getDefaultLayerFX:    false
     *     If true, send all fx settings for enabled fx, even if they match the defaults. If false
     *     layer fx settings will only be sent if they are different from default settings.
     *   getPathData:          false
     *     If true, shape layers will include detailed path data (in the same format as
     *     generator.getLayerShape)
     */
    Generator.prototype.getDocumentInfo = function (documentId, flags) {
        var params = {
            documentId: documentId,
            flags: {
                compInfo:             true,
                imageInfo:            true,
                layerInfo:            true,
                expandSmartObjects:   false,
                getTextStyles:        true,
                getFullTextStyles:    false,
                selectedLayers:       false,
                getCompLayerSettings: true,
                getDefaultLayerFX:    false,
                getPathData:          false
            }
        };

        if (flags) {
            Object.keys(params.flags).forEach(function (key) {
                if (flags.hasOwnProperty(key)) {
                    params.flags[key] = !!flags[key];
                }
            });
        }

        return this.evaluateJSXFileSharedSafe("./jsx/getDocumentInfo.jsx", params).then(function (docInfo) {
            // This JSX will return null if there are no documents open
            if (!docInfo) {
                return Q.reject("No Open Document");
            }
            return docInfo;
        });
    };

    /**
     * Get style information about a document.
     *
     * @param {number} documentId
     * @param {?Object.<string, boolean>} flags Optional override of default flags for
     *   document info request. The optional flags and their default values are:
     *
     *   selectedLayers: false
     *     If true, only return details on the layers that the user has selected. If false,
     *     all layers are returned
     *
     * @return {Promise} resolves to the SON document for the specified Generator document
     *
     * Note: This API should be considered private and may be changed/removed at any
     * time with only a bump to the "patch" version number of generator-core.
     * Use at your own risk.
     */
    Generator.prototype._getStyleInfo = function (documentId, flags) {
        var documentInfoFlags = {
            compInfo:             true,
            imageInfo:            true,
            layerInfo:            true,
            expandSmartObjects:   false,
            getTextStyles:        true,
            getFullTextStyles:    false,
            selectedLayers:       false,
            getCompLayerSettings: true,
            getDefaultLayerFX:    false
        };

        documentId = parseInt(documentId, 10);

        if (!isFinite(documentId)) {
            return Q.reject("documentId parameter for _getStyleInfo must be an integer");
        } else {
            var style = require("./style");

            if (flags && flags.hasOwnProperty("selectedLayers")) {
                documentInfoFlags.selectedLayers = flags.selectedLayers;
            }

            return this.getDocumentInfo(documentId, documentInfoFlags).then(style._extractStyleInfo);
        }
    };

    /**
     * Get a specific layer's generator settings in the given document for a specific plugin.
     *
     * @param {!number} documentId The ID of the document to get the settings for
     * @param {!number} layerId The ID of the layer to get the settings for
     * @param {!String} pluginId The ID of the plugin to get the settings for
     */
    Generator.prototype.getLayerSettingsForPlugin = function (documentId, layerId, pluginId) {
        var self = this,
            params = {
                documentId: documentId,
                layerId: layerId,
                key: escapePluginId(pluginId)
            };

        return this.evaluateJSXFileSharedSafe("./jsx/getGeneratorSettings.jsx", params)
            .then(function (settings) {
                //even though it says "document" it works with any generatorSettings node
                return self.extractDocumentSettings(settings);
            });
    };

    /**
     * Set the specific layer's generator settings in the current document for a specific plugin.
     * @param {!Object} settings The settings to set
     * @param {!number} layerId The ID of the layer to set the settings on
     * @param {!String} pluginId The ID of the plugin to set the settings on
     */
    Generator.prototype.setLayerSettingsForPlugin = function (settings, layerId, pluginId) {
        var params = {
                // Escape the plugin ID because Photoshop can only use
                // letters, digits and underscores for object keys
                key: escapePluginId(pluginId),
                layerId: layerId,
                // Serialize the settings because creating the corresponding ActionDescriptor is harder
                // Wrap the resulting string as { json: ... } because Photoshop needs an object here
                settings: { json: JSON.stringify(settings) }
            };

        return this.evaluateJSXFileSharedSafe("./jsx/setGeneratorSettings.jsx", params);
    };

    /**
     * Get the document-wide generator settings of the current document for a specific plugin.
     *
     * @param {!number} documentId The ID of the document to get the settings for
     * @param {!String} pluginId The ID of the plugin to get the settings for
     */
    Generator.prototype.getDocumentSettingsForPlugin = function (documentId, pluginId) {
        // Note that technically pluginId is optional, but we don't want to make that offical
        var self = this,
            params = {
                documentId: documentId,
                key: escapePluginId(pluginId)
            };

        return this.evaluateJSXFileSharedSafe("./jsx/getGeneratorSettings.jsx", params)
            .then(function (settings) {
                // Don't pass the plugin ID here because due to using params.key above,
                // {{ generatorSettings: { <pluginId>: <settings> }} is shortened to
                // { generatorSettings: <settings> } anyway
                return self.extractDocumentSettings(settings);
            });
    };

    /**
     * Set the document-wide generator settings of the current document for a specific plugin.
     * @param {!Object} settings The settings to set
     * @param {!String} pluginId The ID of the plugin to get the settings for
     */
    Generator.prototype.setDocumentSettingsForPlugin = function (settings, pluginId) {
        var params = {
                // Escape the plugin ID because Photoshop can only use
                // letters, digits and underscores for object keys
                key: escapePluginId(pluginId),
                // Serialize the settings because creating the corresponding ActionDescriptor is harder
                // Wrap the resulting string as { json: ... } because Photoshop needs an object here
                settings: { json: JSON.stringify(settings) }
            };

        return this.evaluateJSXFileSharedSafe("./jsx/setGeneratorSettings.jsx", params);
    };

    /**
     * Extract and parse generator settings, optionally for one plugin only
     * @param {!Object} document The object to extract settings from
     * @param {!Object} document.generatorSettings The stored settings
     * @param {?String} pluginId The ID of the plugin to extract the settings of
     */
    Generator.prototype.extractDocumentSettings = function (document, pluginId) {
        if (!document) { return {}; }

        var self = this,
            // Regardless of whether the source is a call to getDocumentInfo, an imageChanged event,
            // or a call to getDocumentSettings (both for a specific plugin and for all),
            // what Photoshop returns is always an object wrapped into { generatorSettings: ... }
            settings = document.generatorSettings;

        // At this point we're either dealing with the settings for one plugin or for multiple plugins.
        // In the first case, the settings should be wrapped as { json: ... }, otherwise the latter case.
        if (!settings.json) {
            // Do not modify the settings, but create a copy
            var result = {};
            Object.keys(settings).forEach(function (key) {
                // Unescape the plugin IDs to not leak this convention more than necessary
                result[unescapePluginId(key)] = self._parseDocumentSettings(settings[key]);
            });
            settings = result;

            if (pluginId) {
                // Can use the pluginId directly because it was unescaped above
                settings = settings[pluginId] || {};
            }
        } else {
            settings = self._parseDocumentSettings(settings);
        }

        return settings;
    };

    Generator.prototype._parseDocumentSettings = function (settings) {
        if (settings.json) {
            try {
                return JSON.parse(settings.json);
            }
            catch (e) {
                this._logger.error("Could not parse" + settings.json + ": " + e.stack);
            }
        }
        return settings;
    };

    Generator.prototype.subscribeToPhotoshopEvents = function (events) {
        var self = this,
            e,
            i;

        if (!util.isArray(events)) {
            events = [events];
        }

        // Prevent redundant event subscriptions
        for (i = events.length - 1; i >= 0; i--) {
            e = events[i];
            // If we are already subscribed to this event
            if (self._eventSubscriptions[e]) {
                // Remove this event from the list
                events.splice(i, 1);
            } else {
                // Otherwise remember the subscription
                self._eventSubscriptions[e] = true;
            }
        }

        if (events.length > 0) {
            var params = { events : events };
            return self.evaluateJSXFileSharedSafe("./jsx/networkEventSubscribe.jsx", params);
        } else {
            return new Q(true);
        }

    };

    // Photoshop events (e.g. "imageChanged") are managed a little differently than
    // Generator events (e.g. connect, close, error) for two reasons. First, when a user
    // registers for a Photoshop event, we need to actually subscribe to that event over
    // the Photoshop connection. (And, if that subscription fails, we want to clean up
    // properly.) Second, we want to avoid name conflicts with Generator events. (E.g.
    // Photoshop could add an "error" event.) To do this, we have our own registration
    // and removal functions that mimic the regular EventEmitter interface. Event names
    // are prefixed with a constant string, and actual events are dispatched through
    // the usual "emit" codepath.

    Generator.prototype._registerPhotoshopEventHelper = function (event, listener, isOnce) {
        var self = this,
            registerFunction = isOnce ? self.once : self.on;

        if (event === "imageChanged") {
            self._logger.warn("WARNING the imageChanged event is expensive, please consider NOT listening to it");
        }

        self.subscribeToPhotoshopEvents(event).fail(function () {
            self._logger.error("Failed to subscribe to photoshop event %s", event);
            self.removePhotoshopEventListener(event, listener);
        });

        return registerFunction.call(self, PHOTOSHOP_EVENT_PREFIX + event, listener);
    };

    Generator.prototype.onPhotoshopEvent = function (event, listener) {
        return this._registerPhotoshopEventHelper(event, listener, false);
    };
    Generator.prototype.addPhotoshopEventListener = Generator.prototype.onPhotoshopEvent;

    Generator.prototype.oncePhotoshopEvent = function (event, listener) {
        return this._registerPhotoshopEventHelper(event, listener, true);
    };

    Generator.prototype.removePhotoshopEventListener = function (event, listener) {
        // TODO: We could unsubscribe from the PS event if we have no listeners left
        return this.removeListener(PHOTOSHOP_EVENT_PREFIX + event, listener);
    };

    Generator.prototype.photoshopEventListeners = function (event) {
        return this.listeners(PHOTOSHOP_EVENT_PREFIX + event);
    };

    Generator.prototype.emitPhotoshopEvent = function () {
        var args = Array.prototype.slice.call(arguments);
        if (args[0]) {
            args[0] = PHOTOSHOP_EVENT_PREFIX + args[0];
        }

        return this.emit.apply(this, args);
    };

    /**
     * Interpolation types.
     *
     * @const
     * @see Generator.prototype.getPixmap
     * @type {string}
     */
    Object.defineProperties(Generator.prototype, {
        "INTERPOLATION_NEAREST_NEIGHBOR": {
            value: "nearestNeighbor",
            enumerable: true
        },
        "INTERPOLATION_BILINEAR": {
            value: "bilinear",
            enumerable: true
        },
        "INTERPOLATION_BICUBIC": {
            value: "bicubic",
            enumerable: true
        },
        "INTERPOLATION_BICUBIC_SMOOTHER": {
            value: "bicubicSmoother",
            enumerable: true
        },
        "INTERPOLATION_BICUBIC_SHARPER": {
            value: "bicubicSharper",
            enumerable: true
        },
        "INTERPOLATION_BICUBIC_AUTOMATIC": {
            value: "bicubicAutomatic",
            enumerable: true
        },
        "INTERPOLATION_PRESERVE_DETAILS_UPSCALE": {
            value: "preserveDetailsUpscale",
            enumerable: true
        },
        "INTERPOLATION_AUTOMATIC": {
            value: "automaticInterpolation",
            enumerable: true
        }
    });

    /**
     * Get a pixmap representing the pixels of a layer, or just the bounds of that pixmap.
     * The pixmap can be scaled either by providing a horizontal and vertical scaling factor (scaleX/scaleY)
     * or by providing a mapping between an input rectangle and an output rectangle. The input rectangle
     * is specified in document coordinates and should encompass the whole layer.
     * The output rectangle should be of the target size.
     *
     * @param {!number} documentId Document ID
     * @param {!number|{firstLayerIndex: number, lastLayerIndex: number, hidden: Array.<number>=}} layerSpec
     *     Either the layer ID of the desired layer as a number, or an object of the form {firstLayerIndex: number,
     *     lastLayerIndex: number, ?hidden: Array.<number>} specifying the desired index range, inclusive, and
     *     (optionally) an array of indices to hide. Note that the number form takes a layer ID, *not* a layer index.
     * @param {!Object}  settings An object with params to request the pixmap
     * @param {?boolean} settings.boundsOnly Whether to return an object with bounds rather than the pixmap. The
     *     returned object will have the format (but with different numbers):
     *         { bounds: {top: 0, left: 0, bottom: 100, right: 100 } }
     * @param {?Object}  settings.inputRect  Rectangular part of the document to use (usually the layer's bounds)
     * @param {?Object}  settings.outputRect Rectangle into which the the layer should fit
     * @param {?float}   settings.scaleX     The factor by which to scale the image horizontally (1.0 for 100%)
     * @param {?float}   settings.scaleX     The factor by which to scale the image vertically (1.0 for 100%)
     * @param {float=}   settings.inputRect.left    Pixel distance of the rect's left side from the doc's left side
     * @param {float=}   settings.inputRect.top     Pixel distance of the rect's top from the doc's top
     * @param {float=}   settings.inputRect.right   Pixel distance of the rect's right side from the doc's left side
     * @param {float=}   settings.inputRect.bottom  Pixel distance of the rect's bottom from the doc's top
     * @param {float=}   settings.outputRect.left   Pixel distance of the rect's left side from the doc's left side
     * @param {float=}   settings.outputRect.top    Pixel distance of the rect's top from the doc's top
     * @param {float=}   settings.outputRect.right  Pixel distance of the rect's right side from the doc's left side
     * @param {float=}   settings.outputRect.bottom Pixel distance of the rect's bottom from the doc's top
     * @param {float=}   settings.ClipBounds.left   Pixel distance of the rect's left side from the layers's left side
     * @param {float=}   settings.ClipBounds.top    Pixel distance of the rect's top from the layers's top
     * @param {float=}   settings.ClipBounds.right  Pixel distance of the rect's right side from the layers's left side
     * @param {float=}   settings.ClipBounds.bottom Pixel distance of the rect's bottom from the layers's top
     * @param {?string} settings.useJPGEncoding Use aternamte huffman encoding, either optimal, or precomputed
     * @param {?boolean} settings.useSmartScaling Use Photoshop's "smart" scaling to scale layer, which
     *     (confusingly) means that stroke effects (e.g. rounded rect corners) are *not* scaled. (Default: false)
     * @param {?boolean} settings.includeAncestorMasks Cause exported layer to be clipped by any ancestor masks
     *     that are visible (Default: false)
     * @param {?boolean} settings.convertToWorkingRGBProfile: If true, performs a color conversion on the pixels
     *     before they are sent to generator. The color is converted to the working RGB profile (specified for
     *     the document in PS). By default (when this setting is false), the "raw" RGB data is sent, which is
     *     what is usually desired. (Default: false)
     * @param {?string} settings.useICCProfile: String with the ICC color profile to use. If set this overrides
     *     the convertToWorkingRGBProfile flag. A common value is "sRGB IEC61966-2.1". (Default: "")
     * @param {?boolean} settings.getICCProfileData: If true then the final ICC profile for the image is included
     *     along with the returned pixamp (added after PS 16.1)
     * @param {?boolean} settings.allowDither controls whether any dithering could possibly happen in the color
     *     conversion to 8-bit RGB. If false, then dithering will definitely not occur, regardless of either
     *     the value of useColorSettingsDither and the color settings in Photoshop. (Default: false)
     * @param {?boolean} settings.useColorSettingsDither If settings.allowDither is true, then this controls
     *     whether to (if true) defer to the user's color settings in PS, or (if false) to force dither in any
     *     case where a conversion to 8-bit RGB would otherwise be lossy. If allowDither is false, then the
     *     value of this parameter is ignored. (Default: false)
     * @param {string=} settings.interpolationType Force pixmap scaling to use the given interpolation method.
     *     If defined, the value should be one of the Generator.prototype.INTERPOLATION constants. Otherwise,
     *     Photoshop's default interpolation type (as specified in Preferences > Image Interpolation) is used.
     *     (Default: undefined)
     * @param {?boolean} settings.forceSmartPSDPixelScaling: If true, forces PSD Smart objects to be scaled
     *     completely in pixel space (as opposed to scaling vectors, text, etc. in a smoother fashion.) In
     *     PS 15.0 and earlier pixel space scaling was the only option. So, setting this to "true" will replicate
     *     older behavior
     *     (Default: false))
     * @param {?boolean} settings.clipToDocumentBounds: If true, crops returned pixels to the document bounds.
     *     By default, all pixels for the specified layers are returned, even if they lie outside the document
     *     bounds (e.g. if the document was cropped without "Delete Cropped Pixels" checked).
     *     Note that this option *cannot* be used with an inputRect/outputRect scaling. If inputRect/outputRect
     *     is set, this setting will be ignored and the pixels will not be cropped to document bounds.
     *     (Default: false)
     * @param {number=} settings.maxDimension: This is the maximal dimension of pixmap that can be returned
     *     by Photoshop (same for both axis). Raise this value if you need to work with bigger images.
     *     (Default: 10000)
     * @param {number=} settings.compId Layer comp ID (exclusive of settings.compIndex)
     * @param {number=} settings.compIndex Layer comp index (exclusive of settings.compId)
     */
    Generator.prototype.getPixmap = function (documentId, layerSpec, settings) {
        if (arguments.length !== 3) {
            this._logger.warn("Call to getPixmap with " + arguments.length +
                " instead of 3 arguments - outdated plugin?");
        }
        var self              = this,
            executionDeferred = null,
            jsDeferred        = Q.defer(),
            pixmapDeferred    = Q.defer(),
            profileDeferred   = Q.defer(),
            params            = {
                documentId: documentId,
                layerSpec:  layerSpec,
                compId:     settings.compId,
                compIndex:  settings.compIndex,
                inputRect:  settings.inputRect,
                outputRect: settings.outputRect,
                scaleX:     settings.scaleX || 1,
                scaleY:     settings.scaleY || 1,
                bounds:     true,
                boundsOnly: settings.boundsOnly,
                settings: settings.thread,
                useJPGEncoding: settings.useJPGEncoding || "",
                useSmartScaling: settings.useSmartScaling || false,
                includeAncestorMasks: settings.includeAncestorMasks || false,
                convertToWorkingRGBProfile: settings.convertToWorkingRGBProfile || false,
                useICCProfile: settings.useICCProfile || "",
                getICCProfileData: settings.getICCProfileData || false,
                allowDither: settings.allowDither || false,
                useColorSettingsDither: settings.useColorSettingsDither || false,
                interpolationType: settings.interpolationType,
                forceSmartPSDPixelScaling: settings.forceSmartPSDPixelScaling || false,
                clipToDocumentBounds: settings.clipToDocumentBounds || false,
                maxDimension: settings.maxDimension || 10000,
                clipBounds: settings.clipBounds
            };

        // Because of PS communication irregularities in different versions of PS, it's very complicated to
        // know when we're "done" getting responses from executing this JSX file. In various scenarios, the
        // evaluation of the JSX file produces some subset of the following responses in some *arbitrary* order:
        //
        // - A javascript message that is a stringification of an Action Descriptor object
        //  (i.e. "[ActionDescriptor]") -- this should always come back
        // - A javascript message that is a stringification of a JSON object that contains bounds -- currently
        //   this always comes back because "bounds" is hardcoded to "true" in the params list
        // - A pixmap message -- this should come back if and only if boundsOnly is false.
        //
        // The two deferreds at the top of this function (jsDeferred and pixmapDeferred) resolve when we've
        // received all of the expected messages of the respective type with the expected content.
        //
        // Note that this method could be slightly more efficient if we didn't create the pixmapDeffered in cases
        // where it wasn't necessary. But the logic is much simpler if we just create it and then resolve it
        // in cases where we don't need it. When the day comes that Generator is slow because we create one
        // extra deferred every time we generate an image, we'll optimize this.
        executionDeferred = self._sendJSXFile("./jsx/getLayerPixmap.jsx", params, true);

        executionDeferred.promise.progress(function (message) {
            if (message.type === "javascript") {
                // We expect two javascript responses: one from the JSX evaluation result, and
                // one containing bounds information. We only care about the bounds one.
                if (message.value instanceof Object && message.value.hasOwnProperty("bounds")) {
                    jsDeferred.resolve(message.value);
                }
            } else if (message.type === "pixmap") {
                pixmapDeferred.resolve(message.value);
            } else if (message.type === "iccProfile") {
                profileDeferred.resolve(message.value);
            } else {
                self._logger.warn("Unexpected response from Photoshop:", message);
                executionDeferred.reject("Unexpected response from Photoshop");
            }
        });

        executionDeferred.promise.fail(function (err) {
            jsDeferred.reject(err);
            pixmapDeferred.reject(err);
            profileDeferred.reject(err);
        });

        // Resolve the pixmapDeferred now if we aren't actually expecting a pixmap
        if (params.boundsOnly) {
            pixmapDeferred.resolve();
            profileDeferred.resolve();
        }

        // Resolve the profileDefferred if we aren't expecting it to come back
        if (!params.getICCProfileData) {
            profileDeferred.resolve();
        }

        return Q.all([jsDeferred.promise, profileDeferred.promise, pixmapDeferred.promise]).spread(
            function (js, iccProfileBuffer, pixmapBuffer) {
                executionDeferred.resolve();

                if (params.boundsOnly && js && js.bounds) {
                    return js;
                } else if (js && js.bounds && pixmapBuffer) {
                    var pixmap = xpm.Pixmap(pixmapBuffer);
                    pixmap.bounds = js.bounds;
                    if (iccProfileBuffer) {
                        pixmap.iccProfile = iccProfileBuffer;
                    }
                    return pixmap;
                } else {
                    var errStr = "Unexpected response from PS in getLayerPixmap: jsDeferred val: " +
                        JSON.stringify(js) +
                        ", iccProfileBuffer was expected: " + params.getICCProfileData + ", val: " +
                        iccProfileBuffer ? "truthy" : "falsy" +
                        ", pixmapDeferred val: " +
                        pixmapBuffer ? "truthy" : "falsy";
                    throw new Error(errStr);
                }
            }
        );
    };

    /**
     * Get a pixmap representing the pixels of a document in the same layer visibility state
     * that is currently presented in Photoshop.
     *
     * Optionally pass settings with the same available params as getPixmap method.
     *
     * @param {!number} documentId Document ID
     * @param {Object=} settings getPixmap settings
     *
     * @return {Promise.<Pixmap>} Resolves with a pixmap representing the complete document.
     */
    Generator.prototype.getDocumentPixmap = function (documentId, settings) {
        if (documentId === undefined) {
            return Q.reject("Document ID is required");
        } else {
            return this.getDocumentInfo(documentId, {
                compInfo: false,
                imageInfo: false,
                layerInfo: true,
                expandSmartObjects: false,
                getTextStyles: false,
                getFullTextStyles: false,
                selectedLayers: false,
                getCompLayerSettings: true,
                getDefaultLayerFX: false
            }).then(function (document) {
                var layerSpec = {
                    firstLayerIndex: 0,
                    lastLayerIndex: document.layers[0].index,
                    hidden: this._computeHiddenLayers(document)
                };
                if (settings.inputRect === undefined && settings.outputRect === undefined) {
                    delete settings.getExtractParamsForDocBounds;
                    settings.clipToDocumentBounds = true;
                }
                return this.getPixmap(documentId, layerSpec, settings || {});
            }.bind(this));
        }
    };

    /**
     * Recursively walks layers of document and returns hidden ones.
     *
     * @private
     *
     * @param {!Object} parent Whole document or layer of type layerSection
     * @param {boolean=} hideAll If true, all children will be hidden, ignoring their own visibility
     *
     * @return {Array.<number>} Indices of hidden layers
     */
    Generator.prototype._computeHiddenLayers = function (parent, hideAll) {
        return parent.layers.reduce(function (hiddenLayers, layer) {
            var isHidden = hideAll || !layer.visible;
            if (isHidden) {
                hiddenLayers.push(layer.index);
            }
            if (layer.type === "layerSection" && layer.layers && layer.layers.length) {
                hiddenLayers = hiddenLayers.concat(this._computeHiddenLayers(layer, isHidden));
            }
            return hiddenLayers;
        }.bind(this), []);
    };

    /**
     * Returns a promise that resolves to an object detailing the path
     * present on the specified layer. If there is no path present,
     * the promise rejects.
     */
    Generator.prototype.getLayerShape = function (documentId, layerId) {
        var self = this,
            timeoutTimer = null,
            resultDeferred = Q.defer(),
            executionDeferred = self._sendJSXFile("./jsx/getLayerShape.jsx",
                {documentId : documentId, layerId : layerId}, true);

        resultDeferred.promise.finally(function () {
            executionDeferred.resolve(); // done listening for messages

            if (timeoutTimer !== null) {
                clearTimeout(timeoutTimer);
            }
        });

        executionDeferred.promise.progress(function (message) {
            if (timeoutTimer === null) { // First message we've received
                timeoutTimer = setTimeout(function () {
                    self._logger.warn("getLayerShape request timed out");
                    executionDeferred.resolve(); // done listening for messages
                    resultDeferred.reject("timeout");
                }, MULTI_MESSAGE_TIMEOUT);
            }

            if (message.type === "javascript") {
                if (message.value instanceof Object && message.value.hasOwnProperty("path")) {
                    resultDeferred.resolve(message.value);
                } else if (message.value === "") {
                    // sendLayerShapeToNetworkClient returns a JSON object that is an
                    // empty string if there is no shape data on the layer;
                    resultDeferred.reject("layer does not contain a shape");
                }
            }
        });

        executionDeferred.promise.fail(function (err) {
            resultDeferred.reject(err);
        });

        return resultDeferred.promise;
    };

    Generator.prototype._isBoundEmpty = function (bounds) {
        var height = bounds.bottom - bounds.top,
            width = bounds.right - bounds.left;

        return !(Number.isFinite(height) && Number.isFinite(width) &&
                width > 0 && height > 0);
    };

    Generator.prototype._unionBounds = function (boundsA, boundsB) {
        return {
            top: Math.min(boundsA.top, boundsB.top),
            left: Math.min(boundsA.left, boundsB.left),
            bottom: Math.max(boundsA.bottom, boundsB.bottom),
            right: Math.max(boundsA.right, boundsB.right)
        };
    };

    Generator.prototype._intersectBounds = function (boundsA, boundsB) {
        var intersect = {
                top: Math.max(boundsA.top, boundsB.top),
                left: Math.max(boundsA.left, boundsB.left),
                bottom: Math.min(boundsA.bottom, boundsB.bottom),
                right: Math.min(boundsA.right, boundsB.right)
            };

        if (this._isBoundEmpty(intersect)) {
            intersect = {top: 0, left: 0, bottom: 0, right: 0};
        }
        return intersect;
    };

    Generator.prototype._getTotalMaskBounds = function (bounds) {
        var maskBounds = bounds.mask && bounds.mask.enabled && bounds.mask.bounds,
            vectorMaskBounds = bounds.type !== "shapeLayer" && bounds.path && bounds.path.bounds;
        if (maskBounds && this._isBoundEmpty(maskBounds)) {
            maskBounds = undefined;
        }
        if (vectorMaskBounds && this._isBoundEmpty(vectorMaskBounds)) {
            vectorMaskBounds = undefined;
        }
        if (maskBounds && vectorMaskBounds) {
            return this._unionBounds(maskBounds, vectorMaskBounds);
        }

        return maskBounds || vectorMaskBounds;
    };

    Generator.prototype.getDeepBounds = function (layer) {
        var bounds;

        if (!layer.layers || layer.layers.length === 0) {
            bounds = layer.bounds;
        } else {
            layer.layers.forEach(function (sub) {
                var childBounds = this.getDeepBounds(sub);

                if (childBounds) {
                    if (!bounds) {
                        bounds = childBounds;
                    } else {
                        // Compute containing rect of union of bounds and childBounds
                        bounds = this._unionBounds(bounds, childBounds);
                    }
                }
            }, this);
        }

        var maskBounds = this._getTotalMaskBounds(layer);
        if (maskBounds) {
            // compute containing rect of intersection of bounds and maskBounds
            bounds = this._intersectBounds(bounds, maskBounds);
        }

        return bounds;
    };

    /**
     * Computes the settings for getPixmap to achieve a certain scaling/padding result.
     *
     * staticInputBounds is essentially document.layers[i].bounds.
     * visibleInputBounds is essentially document.layers[i].boundsWithFX or pixmap.bounds (better).
     * paddedInputBounds is visibleInputBounds extended by document.layers[i].mask.bounds.
     * paddedInputBounds can therefore extend beyond document.layers[i].mask.bounds (due to effects).
     *
     * For a usage example, see the Image Assets plugin (https://github.com/adobe-photoshop/generator-assets).
     *
     * @param {!Object} settings How to scale the pixmap (includeing padding)
     * @param {?float}  settings.width  Requested width of the image
     * @param {?float}  settings.height Requested height of the image
     * @param {?float}  settings.scaleX Requested horizontal scaling of the image
     * @param {?float}  settings.scaleY Requested vertical scaling of the image
     * @param {!Object<String,float>} staticInputBounds  Bounds for the user-provided content (pixels, shapes)
     * @param {!Object<String,float>} visibleInputBounds Bounds for the visible content (user-provided + effects)
     * @param {!Object<String,float>} paddedInputBounds  Bounds for the whole image (visible + padding)
     * @param {boolean=} clipToBounds if we should clip this object to the document or artboard bounds
     */
    Generator.prototype.getPixmapParams = function (settings,
        staticInputBounds, visibleInputBounds, paddedInputBounds, clipToBounds) {

        // For backwards compatibility
        paddedInputBounds = paddedInputBounds || visibleInputBounds;
        clipToBounds = clipToBounds || paddedInputBounds;

        var // Scaling settings
            targetWidth         = settings.width,
            targetHeight        = settings.height,
            targetScaleX        = settings.scaleX || settings.scale || 1,
            targetScaleY        = settings.scaleY || settings.scale || 1,
            clippedWidth        = Math.min(paddedInputBounds.right, clipToBounds.right) -
                                    Math.max(paddedInputBounds.left, clipToBounds.left),
            clippedHeight       = Math.min(paddedInputBounds.bottom, clipToBounds.bottom) -
                                    Math.max(paddedInputBounds.top, clipToBounds.top),
            clippedTop          = Math.max(0, paddedInputBounds.top),
            clippedLeft         = Math.max(0, paddedInputBounds.left),
            outputRect,
            inputRect,
            clipBounds;

        if (clippedLeft !== paddedInputBounds.left ||
            clippedTop !== paddedInputBounds.top ||
            (clippedLeft + clippedWidth) !== paddedInputBounds.right ||
            (clippedTop + clippedHeight) !== paddedInputBounds.bottom) {
            clipBounds = {
                left: clippedLeft,
                top: clippedTop,
                right: clippedLeft + clippedWidth,
                bottom: clippedTop + clippedHeight
            };
        }

        // Width and height of the bounds
        var staticInputWidth    = staticInputBounds.right   - staticInputBounds.left,
            staticInputHeight   = staticInputBounds.bottom  - staticInputBounds.top,
            visibleInputWidth   = visibleInputBounds.right  - visibleInputBounds.left,
            visibleInputHeight  = visibleInputBounds.bottom - visibleInputBounds.top,
            paddedInputWidth    = paddedInputBounds.right   - paddedInputBounds.left,
            paddedInputHeight   = paddedInputBounds.bottom  - paddedInputBounds.top,

            // How much of the width is due to effects
            effectsInputWidth   = visibleInputWidth  - staticInputWidth,
            effectsInputHeight  = visibleInputHeight - staticInputHeight,
            // How much of the width is due to padding (mask)
            paddingInputWidth   = paddedInputWidth  - visibleInputWidth,
            paddingInputHeight  = paddedInputHeight - visibleInputHeight,

            finalInputWidth = paddingInputWidth ? paddedInputWidth : clippedWidth,
            finalInputHeight = paddingInputHeight ? paddedInputHeight : clippedHeight,
            // Designated image size
            finalOutputWidthFloat = targetWidth  || (finalInputWidth *
                                    (targetHeight  ? (targetHeight  / finalInputHeight) : targetScaleX)),
            finalOutputHeightFloat = targetHeight || (finalInputHeight *
                                    (targetWidth ? (targetWidth / finalInputWidth) : targetScaleY)),

            // Effects are not scaled when the transformation is non-uniform
            finalFloatRatioDiff = Math.abs(finalOutputWidthFloat / finalInputWidth -
                                            finalOutputHeightFloat / finalInputHeight),
            floatDiffEpislon = 2e-16,
            effectsScaled       = (targetWidth || targetHeight) ? finalFloatRatioDiff < floatDiffEpislon :
                                                                  targetScaleX === targetScaleY,

            finalOutputWidth   = Math.round(finalOutputWidthFloat),
            finalOutputHeight  = Math.round(finalOutputHeightFloat),

            finalOutputScaleX  = finalOutputWidthFloat  / finalInputWidth,
            finalOutputScaleY  = finalOutputHeightFloat / finalInputHeight,

            // How much to scale everything that can be scaled (static + padding, maybe effects)
            scaleX              = effectsScaled ? finalOutputScaleX : finalOutputScaleX +
                                    (effectsInputWidth  * (finalOutputScaleX - 1)) /
                                    (staticInputWidth  + paddingInputWidth),
            scaleY              = effectsScaled ? finalOutputScaleY : finalOutputScaleY +
                                    (effectsInputHeight * (finalOutputScaleY - 1)) /
                                    (staticInputHeight + paddingInputHeight),

            // The expected size of the pixmap returned by Photoshop (does not include padding)
            visibleOutputWidth  = effectsScaled ? scaleX * visibleInputWidth :
                                                        scaleX * staticInputWidth + effectsInputWidth,
            visibleOutputHeight = effectsScaled ? scaleY * visibleInputHeight :
                                                        scaleY * staticInputHeight + effectsInputHeight;


        if (targetWidth || targetHeight) {
            inputRect = {
                left:   staticInputBounds.left,
                top:    staticInputBounds.top,
                right:  staticInputBounds.left + staticInputWidth,
                bottom: staticInputBounds.top  + staticInputHeight
            };
            outputRect = {
                left:   0,
                top:    0,
                right:  Math.round(
                            visibleOutputWidth  - effectsInputWidth  * (effectsScaled ? scaleX : 1)),
                bottom: Math.round(
                            visibleOutputHeight - effectsInputHeight * (effectsScaled ? scaleY : 1))
            };
        }

        // The settings for getPixmap
        return {

            inputRect: inputRect,
            outputRect: outputRect,

            scaleX: targetScaleX,
            scaleY: targetScaleY,

            clipBounds: clipBounds,

            // The padding depends on the actual size of the returned image, therefore provide a function
            getPadding: function (pixmapWidth, pixmapHeight) {
                // Find out if the mask extends beyond the visible pixels
                var paddingWanted;
                ["top", "left", "right", "bottom"].forEach(function (key) {
                    if (paddedInputBounds[key] !== visibleInputBounds[key]) {
                        paddingWanted = true;
                        return false;
                    }
                });

                // When Photoshop produces inaccurate results, the padding is adjusted to compensate
                // When no padding is requested, this may be unwanted, so return a padding of 0px
                if (!paddingWanted) {
                    return { left: 0, top: 0, right: 0, bottom: 0 };
                }

                var // How much padding is necessary in both dimensions
                    missingWidth  = finalOutputWidth  - pixmapWidth,
                    missingHeight = finalOutputHeight - pixmapHeight,
                    // How of the original padding was on which side (default 0)
                    leftRatio     = paddingInputWidth === 0 ? 0 :
                        ((visibleInputBounds.left - paddedInputBounds.left) / paddingInputWidth),
                    topRatio      = paddingInputHeight === 0 ? 0 :
                        ((visibleInputBounds.top  - paddedInputBounds.top)  / paddingInputHeight),
                    // Concrete padding size on one side so the other side can use the rest
                    leftPadding   = Math.round(leftRatio * missingWidth),
                    topPadding    = Math.round(topRatio  * missingHeight);

                // Padding: how many transparent pixels to add on which side
                return {
                    left:   Math.max(0, leftPadding),
                    top:    Math.max(0, topPadding),
                    right:  Math.max(0, missingWidth  - leftPadding),
                    bottom: Math.max(0, missingHeight - topPadding)
                };
            },

            getExtractParamsForDocBounds: function (finalWidth, finalHeight) {
                var outputWidth = Math.max(finalOutputWidth, visibleOutputWidth),
                    outputHeight = Math.max(finalOutputHeight, visibleOutputHeight),
                    unexpectedExtraWidth = Math.max(0, finalWidth - outputWidth),
                    unexpectedExtraHeight = Math.max(0, finalHeight - outputHeight);

                //if the image and effects are completely contained there is nothing more to do
                if (paddedInputBounds.top >= clipToBounds.top && paddedInputBounds.top <= clipToBounds.bottom &&
                    paddedInputBounds.left >= clipToBounds.left && paddedInputBounds.left <= clipToBounds.right &&
                    paddedInputBounds.right <= clipToBounds.right && paddedInputBounds.right >= clipToBounds.left &&
                    paddedInputBounds.bottom <= clipToBounds.bottom && paddedInputBounds.bottom >= clipToBounds.top) {
                    if (unexpectedExtraHeight || unexpectedExtraWidth) {
                        return {
                            x:0,
                            y:0,
                            height: Math.round(unexpectedExtraHeight ? visibleOutputHeight : finalHeight),
                            width: Math.round(unexpectedExtraWidth ? visibleOutputWidth : finalWidth)
                        };
                    }
                    return;
                }

                //if the image and effects are completely outside there is nothing to extract
                if (paddedInputBounds.top > clipToBounds.bottom || paddedInputBounds.left > clipToBounds.right ||
                    paddedInputBounds.right < clipToBounds.left || paddedInputBounds.bottom < clipToBounds.top) {
                    return {x:0, y:0, height: 0, width: 0};
                }

                var deltaTop = 0,
                    deltaLeft = 0,
                    deltaRight = 0,
                    deltaBottom = 0,
                    clipDeltaTop = Math.abs(Math.min(0, paddedInputBounds.top - clipToBounds.top)),
                    clipDeltaLeft = Math.abs(Math.min(0, paddedInputBounds.left - clipToBounds.left)),
                    clipDeltaRight = Math.abs(Math.min(0, clipToBounds.right - paddedInputBounds.right)),
                    clipDeltaBottom = Math.abs(Math.min(0, clipToBounds.bottom - paddedInputBounds.bottom));

                var calcScaledDelta = function (clipDelta, staticDelta, effectsDelta, paddingDelta, scale) {
                    var finalDelta = 0;
                    if (effectsScaled) {
                        finalDelta = clipDelta * scale;
                    } else {
                        finalDelta = Math.min(staticDelta, clipDelta) * scale;
                        clipDelta = Math.max(0, clipDelta - staticDelta);
                        finalDelta += Math.min(effectsDelta, clipDelta);
                        clipDelta = Math.max(0, clipDelta - staticDelta);
                        finalDelta += Math.min(paddingDelta, clipDelta);
                    }

                    return finalDelta;
                };

                //if we're cropping include any padding and effects on that side
                if (clipDeltaTop) {
                    var staticDeltaTop = Math.abs(Math.min(0, staticInputBounds.top - clipToBounds.top)),
                        effectsDeltaTop = staticInputBounds.top - visibleInputBounds.top,
                        paddingDeltaTop = visibleInputBounds.top - paddedInputBounds.top;

                    deltaTop = calcScaledDelta(clipDeltaTop, staticDeltaTop, effectsDeltaTop,
                                               paddingDeltaTop, scaleY);
                }

                if (clipDeltaLeft) {
                    var staticDeltaLeft = Math.abs(Math.min(0, staticInputBounds.left - clipToBounds.left)),
                        effectsDeltaLeft = staticInputBounds.left - visibleInputBounds.left,
                        paddingDeltaLeft = visibleInputBounds.left - paddedInputBounds.left;

                    deltaLeft = calcScaledDelta(clipDeltaLeft, staticDeltaLeft, effectsDeltaLeft,
                                                paddingDeltaLeft, scaleX);
                }

                if (clipDeltaRight) {
                    var staticDeltaRight = Math.abs(Math.min(0, clipToBounds.right - staticInputBounds.right)),
                        effectsDeltaRight = visibleInputBounds.right - staticInputBounds.right,
                        paddingDeltaRight = paddedInputBounds.right - visibleInputBounds.right;

                    deltaRight = calcScaledDelta(clipDeltaRight, staticDeltaRight, effectsDeltaRight,
                                                 paddingDeltaRight, scaleX);
                }

                if (clipDeltaBottom) {
                    var staticDeltaBottom = Math.abs(Math.min(0, clipToBounds.bottom - staticInputBounds.bottom)),
                        effectsDeltaBottom = visibleInputBounds.bottom - staticInputBounds.bottom,
                        paddingDeltaBottom = paddedInputBounds.bottom - visibleInputBounds.bottom;

                    deltaBottom = calcScaledDelta(clipDeltaBottom, staticDeltaBottom, effectsDeltaBottom,
                                                  paddingDeltaBottom, scaleY);
                }

                return {
                    x: Math.round(deltaLeft),
                    y: Math.round(deltaTop),
                    width: Math.max(0, Math.round(finalWidth - deltaLeft - deltaRight - unexpectedExtraWidth)),
                    height: Math.max(0, Math.round(finalHeight - deltaTop - deltaBottom - unexpectedExtraHeight))
                };

            }
        };
    };

    Generator.prototype._parsePixmapProperties = function (pixmap) {
        // ensure that arguments are of the correct type
        pixmap.width          = parseInt(pixmap.width, 10);
        pixmap.height         = parseInt(pixmap.height, 10);
        pixmap.bitsPerChannel = parseInt(pixmap.bitsPerChannel, 10);
    };

    Generator.prototype._parsePixmapSaveSettings = function (settings) {
        // ensure that arguments are of the correct type
        if (settings._scale) {
            settings._scale = parseFloat(settings._scale);
        }

        if (settings.hasOwnProperty("quality")) {
            settings.quality  = parseInt(settings.quality, 10);
        }
        if (settings.hasOwnProperty("ppi")) {
            settings.ppi      = parseFloat(settings.ppi);
        }

        if (settings.hasOwnProperty("background")) {
            if (!util.isArray(settings.background)) {
                throw new Error("settings.background is not an array");
            }
            if (settings.background.length !== 4) {
                throw new Error("settings.background must contain 4 values for RGBA");
            }
            settings.background[0] = parseInt(settings.background[0], 10);
            settings.background[1] = parseInt(settings.background[1], 10);
            settings.background[2] = parseInt(settings.background[2], 10);
            settings.background[3] = parseFloat(settings.background[3]);
        }

    };

    /**
     * @param {!Pixmap}  pixmap                An object representing the layer's image
     * @param {!integer} pixmap.width          The width of the image
     * @param {!integer} pixmap.height         The height of the image
     * @param {!Buffer}  pixmap.pixels         A buffer containing the actual pixel data
     * @param {!integer} pixmap.bitsPerChannel Bits per channel
     * @param {!String}  path                  The path to write to
     * @param {!Object}  settings              An object with settings for converting the image
     * @param {!String}  settings.format       ImageMagick output format
     * @param {?integer} settings.quality      A number indicating the quality - the meaning depends on the format
     * @param {?boolean} settings.lossless     Lossless compression for webp format
     * @param {?number}  settings.ppi          The image's pixel density
     * @param {?Object}  settings.padding      Padding, in pixels, to add around the saved image. Should have the
     *    format { top: 0, left: 0, bottom: 0, right: 0 }. Padding will be transparent (for formats that support
     *    transparency) or white.
     * @param {?Object}  settings.extract      Extract, coorindates and size to extract from the pixmap. Should have
     *    ths format { x: number, y: number, height: number, width number }. All numbers should be positive. X and Y
     *    can be 0, width and height cannot
     * @param {?Array.<number>=} settings.background    Background color as RGBA array (default [0,0,0,0.0])
     *    RGB values are in [0,255] and the A value is in [0,1]
     * @param {?number}  settings._scale       A scale factor that causes the image to be resized using convert
     *    (This API should be considered private and may be removed at any time with only a bump to the "patch"
     *    version number of generator-core. Use at your own risk.)
     * @param {?boolean=} settings.usePngquant  If true, quantize 8-bit pngs using pngquant instead of convert
     * @param {?boolean=} settings.useFlite  If true, use flite to for image encoding instead of convert
     * @param {?string=} settings.useJPGEncoding Specify which type of huffman encoding for jpegs
     * @return {Promise.<String>} Promise that resolves to the path of the file after write is complete and the
     *    file stream is closed
     */
    Generator.prototype.savePixmap = function (pixmap, path, settings) {
        this._parsePixmapProperties(pixmap);
        this._parsePixmapSaveSettings(settings);

        return convert.savePixmap(this._paths, pixmap, path, settings, this._logger);
    };

    /**
     * @param {!Pixmap}  pixmap                An object representing the layer's image. See savePixmap.
     * @param {!Stream}  outputStream          A Stream object to receive the converted pixmap.
     * @param {!Object}  settings              An object with settings for converting the image. See savePixmap.
     * @return {Promise} Promise that resolves (with an empty value) after outputStream is closed.
     */
    Generator.prototype.streamPixmap = function (pixmap, outputStream, settings) {
        this._parsePixmapProperties(pixmap);
        this._parsePixmapSaveSettings(settings);
        return convert.streamPixmap(this._paths, pixmap, outputStream, settings, this._logger);
    };

    /**
     * Get an SVG representing the layer. Returns a promise that resolves to an SVG string.
     * The SVG can optionally be scaled proportionately using the "scale" parameter of the "settings" object
     *
     * @param {!integer} documentId Document ID
     * @param {!integer} layerId Layer ID
     * @param {=Object} settings An object with params to request the pixmap
     * @param {?float} settings.scale  The factor by which to scale the SVG (1.0 for 100%)
     */
    Generator.prototype.getSVG = function (documentId, layerId, settings) {
        // documentId optional to avoid revving API
        documentId = typeof(documentId) === "number" ? documentId : null;

        var scale = settings && settings.hasOwnProperty("scale") ? settings.scale : 1;

        var params = {
            layerId: layerId,
            layerScale: scale,
            documentId: documentId
        };


        return (this.evaluateJSXFileSharedSafe("./jsx/getLayerSVG.jsx", params)
            .then(function (result) {
                return decodeURI(result.svgText);
            })
        );
    };

    /**
     * Get a list of guides in document.
     * Returns a promise that resolves with the sets of horizontal and vertical guide positions in the given document
     *
     * @param {!integer} documentId Document ID
     *
     * @return {Promise.<{horizontal: Array.<number>, vertical: Array.<number>}>}
     */
    Generator.prototype.getGuides = function (documentId) {

        if (documentId === undefined) {
            return Q.reject("Document ID is required");
        } else {
            return this.evaluateJSXFileSharedSafe("./jsx/getGuides.jsx", { documentId: documentId })
                .then(function (serializedGuides) {
                    var guideParts = serializedGuides.split(";").map(function (guides) {
                        // when no guides in this direction
                        if (guides === "") {
                            return [];
                        }
                        // otherwise parse coordinates
                        return guides.split(":").map(function (coordinate) { return parseFloat(coordinate); });
                    });
                    return {
                        horizontal: guideParts[0],
                        vertical: guideParts[1]
                    };
                });
        }
    };

    /**
     *  Log a string in Photoshop's "Headlights" database for feature usage analysis.
     *  Note that the data will only actually be logged if the user has opted in to
     *  providing customer feedback. If they have not opted in, the string will be
     *  discarded.
     *
     *  This method is intended to be used only by Adobe-created Generator plugins,
     *  since third parties don't have a way to access headlights data.
     *
     *  @private
     *
     *  @param {!string} event The string to log in Headlights
     *
     *  @return {Promise} resolved/rejected when request completes/errors
     */
    Generator.prototype._logHeadlights = function (event) {
        return this.evaluateJSXFileSharedSafe("./jsx/logHeadlights.jsx", { event : event });
    };

    /**
     *  Log a plugin loaded data group record in to Photoshop's "Headlights" database
     *
     *  This method is intended to be used only by Adobe-created Generator plugins,
     *  since third parties don't have a way to access headlights data.
     *
     *  @private
     *
     *  @param {!string} pluginName
     *  @param {!string} pluginVersion
     *
     *  @return {Promise} resolved/rejected when request completes/errors
     */
    Generator.prototype._logHeadlightsPluginLoaded = function (pluginName, pluginVersion) {
        return this.evaluateJSXFileSharedSafe("./jsx/logHeadlightsPluginLoaded.jsx",
            { pluginName : pluginName, pluginVersion: pluginVersion });
    };

    Generator.prototype.shutdown = function () {
        if (this._photoshop) {
            try {
                this._photoshop.disconnect();
            } catch (photoshopDisconnectException) {
                // do nothing
            }
            this._photoshop = null;
        }
    };

    Generator.prototype.isConnected = function () {
        return (this._photoshop && this._photoshop.isConnected());
    };

    Generator.prototype.getPluginMetadata = function (directory) {
        var fs = require("fs"),
            resolve = require("path").resolve,
            metadata = null;

        // Make sure a directory was specified
        if (!fs.statSync(directory).isDirectory()) {
            throw new Error("Argument error: specified path is not a directory");
        }

        // Load metadata
        try {
            metadata = require(resolve(directory, "package.json"));
        } catch (metadataError) {
            throw new Error("Error reading package.json file for plugin at path '" +
                directory + "': " + metadataError.message);
        }

        // Ensure plugin has a name
        if (!(metadata && metadata.name && typeof metadata.name === "string")) {
            throw new Error("Invalid metadata for plugin at path '" + directory +
                "' (plugins must have a valid package.json file with 'name' property): " +
                JSON.stringify(metadata));
        }

        return metadata;
    };

    Generator.prototype.checkPluginCompatibility = function (metadata) {
        var result = {compatible: true, message: null};

        if (!metadata["generator-core-version"]) {
            // Still compatible, but has a warning.
            result.compatible = true;
            result.message = "Warning: Plugin '" + metadata.name +
                "' did not specify which versions of generator-core it is compatible with." +
                " It will be loaded anyway, but providing generator-core-version" +
                " in its package.json is recommended.";
        } else if (packageConfig.version &&
            !semver.satisfies(packageConfig.version, metadata["generator-core-version"])) {
            result.compatible = false;
            result.message = "The plugin " + metadata.name + " is incompatible with this version of generator-core." +
                " generator-core version: " + packageConfig.version +
                ", plugin compatibility: " + metadata["generator-core-version"];
        }

        return result;
    };

    Generator.prototype.loadPlugin = function (directory) {
        var metadata = null,
            compatibility = null,
            self = this;

        function handleIncompatiblePlugin(metadata) {
            self.alert(PLUGIN_INCOMPATIBLE_MESSAGE, [metadata.name]);
            // TODO: Record that we have given an alert for this plugin, and only
            // alert if we've never alerted for it before.
        }

        // Get the metadata
        try {
            metadata = self.getPluginMetadata(directory);
        } catch (metadataError) {
            throw new Error("Could not load plugin: " + metadataError.message);
        }

        // Check against blacklist
        if (PLUGIN_BLACKLIST.has(metadata.name)) {
            throw new Error("Plugin is blacklisted");
        }

        // Check if it is compatible
        compatibility = self.checkPluginCompatibility(metadata);
        if (!compatibility.compatible) {
            handleIncompatiblePlugin(metadata);
            self._logger.error(compatibility.message);
            throw new Error(compatibility.message);
        } else if (compatibility.message) {
            self._logger.warn(compatibility.message);
        }

        // Check for uniqueness
        if (self._plugins[PLUGIN_KEY_PREFIX + metadata.name]) {
            throw new Error("Attempted to load a plugin with a name that is already used. Path: '" +
                directory + "', name: '" + metadata.name + "'");
        }

        // Do the actual plugin load
        try {
            self._logger.info("Loading plugin: %s (v%s) from: %s", metadata.name, metadata.version, directory);
            // NOTE: We don't need to worry about accidentally requiring the same plugin twice.
            // If the user did try to load it twice, require's caching would return the same
            // package.json both times (even if the package.json changed on disk), and so
            // we'd get the same name both times, and bail in the "if" branch above.
            var plugin = require(directory),
                config = self._config[metadata.name] || {},
                logger = self._loggerManager.createLogger(metadata.name);

            plugin.init(this, config, logger);
            self._plugins[PLUGIN_KEY_PREFIX + metadata.name] = {
                metadata: metadata,
                plugin: plugin,
                config: config,
                logger: logger
            };
            self._logger.info("Plugin loaded: %s", metadata.name);
            self._logHeadlightsPluginLoaded(metadata.name, metadata.version);
        } catch (loadError) {
            throw new Error("Could not load plugin at path '" + directory + "': " + loadError.message);
        }
    };

    /**
     * Returns an already-loaded plugin with the specified name. If no plugin
     * with that name has been loaded, returns null.
     */
    Generator.prototype.getPlugin = function (name) {
        var plugin = null;
        if (this._plugins[PLUGIN_KEY_PREFIX + name] &&
            this._plugins[PLUGIN_KEY_PREFIX + name].hasOwnProperty("plugin")) {
            plugin = this._plugins[PLUGIN_KEY_PREFIX + name].plugin;
        }

        return plugin;
    };

    Generator.prototype.checkConnection = function () {
        var self = this,
            aliveDeferred = Q.defer();

        var id = self._photoshop.sendKeepAlive();
        self._jsMessageDeferreds[id] = aliveDeferred;

        return aliveDeferred.promise;
    };

    /**
     * Asynchronously get the table of custom options for the given plugin. These
     * options can be accessed via ExtendScript and persist until Photoshop is relaunched.
     *
     * @param {!string} pluginId The ID of the plugin for which to retrieve custom options
     * @return {Promise.<Object.<string, *>>} Resolves with the table of options
     */
    Generator.prototype.getCustomOptions = function (pluginId) {
        var key = escapePluginId(pluginId),
            params = {
                key: key
            };

        // We stored stringified settings, but the Photoshop connection tries to
        // parse JSON responses from ExtendScript automatically.
        return this.evaluateJSXFileSharedSafe("./jsx/getCustomOptions.jsx", params)
            .then(function (settings) {
                if (typeof settings === "object") {
                    return settings;
                } else if (settings === "") {
                    return {};
                } else {
                    this._logger.warn("Unexpected custom options:", settings);
                    return {};
                }
            }.bind(this))
            .catch(function () {
                return {};
            });
    };

    /**
     * Asynchronously set the entire table of custom options for the given plugin.
     * These options can be accessed via ExtendScript and persist until Photoshop
     * is relaunched.
     *
     * Important: the old and new custom otions for the specified pluginId are *NOT*
     * merged. Any data in the old custom options is thrown away. If you simply want
     * to update or remove a single custom option for pluginId, consider the
     * updateCustomOption and deleteCustomOption methods on the Generator object.
     *
     * @param {!string} pluginId The ID of the plugin for which to set custom options
     * @param {!Object.<string, *>} settings The table of options to be set for
     *      the plugin. The values of the table must be JSON-stringifyable.
     * @return {Promise} Resolves once the custom options have been set
     */
    Generator.prototype.setCustomOptions = function (pluginId, settings) {
        var pluginKey = escapePluginId(pluginId),
            stringifiedSettings;

        try {
            stringifiedSettings = JSON.stringify(settings);
        } catch (ex) {
            return Q.reject(ex);
        }

        var params = {
            key: pluginKey,
            settings: stringifiedSettings,
            persistent: false
        };

        return this.evaluateJSXFileSharedSafe("./jsx/setCustomOptions.jsx", params);
    };

    /**
     * Asynchronously updates a single custom option for the the given plugin.
     * The entry is added to the table of custom options if it does not already
     * exist. Other entries in the table are not affected.
     *
     * @param {!string} pluginId The ID of the plugin for which to set the custom option
     * @param {!string} key The key of the option to set
     * @param {*} value The value of the option to set. Must be JSON-stringifyable.
     * @return {Promise} Resolves once the custom options have been updated
     */
    Generator.prototype.updateCustomOption = function (pluginId, key, value) {
        return this.getCustomOptions(pluginId)
            .then(function (settings) {
                settings[key] = value;
                return this.setCustomOptions(pluginId, settings);
            }.bind(this));
    };

    /**
     * Asynchronously deletes a single custom option for the the given plugin.
     * Other entries in the table are not affected.
     *
     * @param {!string} pluginId The ID of the plugin for which to set the custom option
     * @param {!string} key The key of the option to delete
     * @return {Promise} Resolves once the custom options have been updated
     */
    Generator.prototype.deleteCustomOption = function (pluginId, key) {
        return this.getCustomOptions(pluginId)
            .then(function (settings) {
                if (settings.hasOwnProperty(key)) {
                    delete settings[key];
                    return this.setCustomOptions(pluginId, settings);
                }
            }.bind(this));
    };

    /**
     * Start a Websocket server for use by the given plugin. A desired port may
     * be specified; if none is specified then the port is chosen dynamically.
     *
     * If a domain module is supplied, it will be used to register a domain after the server is started.
     * @see DomainManager.loadDomainModule()
     *
     * @param {!string} pluginId The ID of the plugin for which to start the server
     * @param {number=} desiredPort Optional desired port number for the server
     * @param {Module=} domain Optional module with which to register a domain for this server
     * @param {string=} origin Optional origin string used to verify websocket client connections
     * @return {Promise.<number>} Resolves with the actual port number on which
     *      the server is listening.
     */
    Generator.prototype.startWebsocketServer = function (pluginId, desiredPort, domain, origin) {
        var pluginConfig = this._plugins[PLUGIN_KEY_PREFIX + pluginId];
        if (!pluginConfig) {
            return Q.reject("Plugin not loaded: " + pluginId);
        }

        if (!pluginConfig.websocketServerPromise) {
            pluginConfig.websocketServer = new Server(this, pluginConfig.logger, origin);
            pluginConfig.websocketServerPromise = pluginConfig.websocketServer.start(desiredPort)
                .then(function (actualPort) {
                    return this.updateCustomOption(pluginId, "websocketServerPort", actualPort)
                        .thenResolve(actualPort);
                }.bind(this))
                .then(function (actualPort) {
                    if (domain) {
                        return pluginConfig.websocketServer.registerDomain(domain)
                            .thenResolve(actualPort);
                    } else {
                        return actualPort;
                    }
                })
                .catch(function (err) {
                    return this.stopWebsocketServer(pluginId)
                        .thenReject(err);
                }.bind(this));
        }

        return pluginConfig.websocketServerPromise;
    };

    /**
     * Stop the running Websocket server for the given plugin.
     *
     * @param {!string} pluginId The ID of the plugin for which to stop the server
     * @return {Promise}
     */
    Generator.prototype.stopWebsocketServer = function (pluginId) {
        var pluginConfig = this._plugins[PLUGIN_KEY_PREFIX + pluginId];
        if (!pluginConfig) {
            return Q.reject("Plugin not loaded: " + pluginId);
        }

        if (!pluginConfig.websocketServerPromise) {
            return Q.reject("Websocket server not running");
        }

        var websocketServer = pluginConfig.websocketServer,
            websocketServerPromise = pluginConfig.websocketServerPromise;

        delete pluginConfig.websocketServer;
        delete pluginConfig.websocketServerPromise;

        return websocketServerPromise.finally(function () {
            websocketServer.stop();
            return this.deleteCustomOption(pluginId, "websocketServerPort");
        }.bind(this));
    };

    exports.Generator         = Generator;
    exports.createGenerator   = createGenerator;
    exports.logStream         = null; // this is now set by app.js, leaving here for backward compat
    exports._escapePluginId   = escapePluginId;
    exports._unescapePluginId = unescapePluginId;

}());
