"use strict";

var hooks           = require("./hooks");
var asyncTasks      = require("./async-tasks");
var config          = require("./config");
var connectUtils    = require("./connect-utils");
var utils           = require("./utils");
var logger          = require("./logger");

var eachSeries      = utils.eachSeries;
var _               = require("../lodash.custom");
var EE              = require("easy-extender");

/**
 * Required internal plugins.
 * Any of these can be overridden by deliberately
 * causing a name-clash.
 */
var defaultPlugins = {
    "logger":        logger,
    "socket":        require("./sockets"),
    "file:watcher":  require("./file-watcher"),
    "server":        require("./server"),
    "tunnel":        require("./tunnel"),
    "client:script": require("browser-sync-client"),
    "UI":            require("browser-sync-ui")
};

/**
 * @constructor
 */
var BrowserSync = function (emitter) {

    var bs      = this;

    bs.cwd      = process.cwd();
    bs.active   = false;
    bs.paused   = false;
    bs.config   = config;
    bs.utils    = utils;
    bs.events   = bs.emitter = emitter;

    bs._userPlugins   = [];
    bs._reloadQueue   = [];
    bs._cleanupTasks  = [];
    bs._browserReload = false;

    // Plugin management
    bs.pluginManager = new EE(defaultPlugins, hooks);
};

/**
 * Call a user-options provided callback
 * @param name
 */
BrowserSync.prototype.callback = function (name) {

    var bs  = this;
    var cb  = bs.options.getIn(["callbacks", name]);

    if (_.isFunction(cb)) {
        cb.apply(bs.publicInstance, _.toArray(arguments).slice(1));
    }
};

/**
 * @param {Map} options
 * @param {Function} cb
 * @returns {BrowserSync}
 */
BrowserSync.prototype.init = function (options, cb) {

    /**
     * Safer access to `this`
     * @type {BrowserSync}
     */
    var bs = this;

    /**
     * Set user-provided callback, or assign a noop
     * @type {Function}
     */
    bs.cb  = cb || utils.defaultCallback;

    /**
     * Verify provided config.
     * Some options are not compatible and will cause us to
     * end the process.
     */
    if (!utils.verifyConfig(options, bs.cb)) {
        return;
    }

    /**
     * Save a reference to the original options
     * @type {Map}
     * @private
     */
    bs._options = options;

    /**
     * Set additional options that depend on what the
     * user may of provided
     * @type {Map}
     */
    bs.options  = require("./options").update(options);

    /**
     * Kick off default plugins.
     */
    bs.pluginManager.init();

    /**
     * Create a base logger & debugger.
     */
    bs.logger   = bs.pluginManager.get("logger")(bs.events, bs);
    bs.debugger = bs.logger.clone({useLevelPrefixes: true});
    bs.debug    = bs.debugger.debug;

    /**
     * Run each setup task in sequence
     */
    eachSeries(
        asyncTasks,
        taskRunner(bs),
        tasksComplete(bs)
    );

    return this;
};

/**
 * Run 1 setup task.
 * Each task is a pure function.
 * They can return options or instance properties to set,
 * but they cannot set them directly.
 * @param {BrowserSync} bs
 * @returns {Function}
 */
function taskRunner (bs) {

    return function (item, cb) {

        bs.debug("-> {yellow:Starting Step: " + item.step);

        /**
         * Execute the current task.
         */
        item.fn(bs, executeTask);

        function executeTask(err, out) {

            /**
             * Exit early if any task returned an error.
             */
            if (err) {
                return cb(err);
            }

            /**
             * Act on return values (such as options to be set,
             * or instance properties to be set
             */
            if (out) {
                handleOut(bs, out);
            }

            bs.debug("+  {green:Step Complete: " + item.step);

            cb();
        }
    };
}

/**
 * @param bs
 * @param out
 */
function handleOut (bs, out) {
    /**
     * Set a single/many option.
     */
    if (out.options) {
        setOptions(bs, out.options);
    }

    /**
     * Any options returned that require path access?
     */
    if (out.optionsIn) {
        out.optionsIn.forEach(function (item) {
            bs.setOptionIn(item.path, item.value);
        });
    }

    /**
     * Any instance properties returned?
     */
    if (out.instance) {
        Object.keys(out.instance).forEach(function (key) {
            bs[key] = out.instance[key];
        });
    }
}

/**
 * Update the options Map
 * @param bs
 * @param options
 */
function setOptions (bs, options) {

    /**
     * If multiple options were set, act on the immutable map
     * in an efficient way
     */
    if (Object.keys(options).length > 1) {
        bs.setMany(function (item) {
            Object.keys(options).forEach(function (key) {
                item.set(key, options[key]);
                return item;
            });
        });
    } else {
        Object.keys(options).forEach(function (key) {
            bs.setOption(key, options[key]);
        });
    }
}

/**
 * At this point, ALL async tasks have completed
 * @param {BrowserSync} bs
 * @returns {Function}
 */
function tasksComplete (bs) {

    return function (err) {

        if (err) {
            bs.logger.setOnce("useLevelPrefixes", true).error(err.message);
        }

        /**
         * Set active flag
         */
        bs.active = true;

        /**
         * @deprecated
         */
        bs.events.emit("init", bs);

        /**
         * This is no-longer needed as the Callback now only resolves
         * when everything (including slow things, like the tunnel) is ready.
         * It's here purely for backwards compatibility.
         * @deprecated
         */
        bs.events.emit("service:running", {
            options: bs.options,
            baseDir: bs.options.getIn(["server", "baseDir"]),
            type:    bs.options.get("mode"),
            port:    bs.options.get("port"),
            url:     bs.options.getIn(["urls", "local"]),
            urls:    bs.options.get("urls").toJS(),
            tunnel:  bs.options.getIn(["urls", "tunnel"])
        });

        /**
         * Call any option-provided callbacks
         */
        bs.callback("ready", null, bs);

        /**
         * Finally, call the user-provided callback given as last arg
         */
        bs.cb(null, bs);
    };
}

/**
 * @param module
 * @param opts
 * @param cb
 */
BrowserSync.prototype.registerPlugin = function (module, opts, cb) {

    var bs = this;

    bs.pluginManager.registerPlugin(module, opts, cb);

    if (module["plugin:name"]) {
        bs._userPlugins.push(module);
    }
};

/**
 * Get a plugin by name
 * @param name
 */
BrowserSync.prototype.getUserPlugin = function (name) {

    var bs = this;

    var items = bs.getUserPlugins(function (item) {
        return item["plugin:name"] === name;
    });

    if (items && items.length) {
        return items[0];
    }

    return false;
};

/**
 * @param {Function} [filter]
 */
BrowserSync.prototype.getUserPlugins = function (filter) {

    var bs = this;

    filter = filter || function () {
        return true;
    };

    /**
     * Transform Plugins option
     */
    bs.userPlugins = bs._userPlugins.filter(filter).map(function (plugin) {
        return {
            name: plugin["plugin:name"],
            active: plugin._enabled,
            opts: bs.pluginManager.pluginOptions[plugin["plugin:name"]]
        };
    });

    return bs.userPlugins;
};

/**
 * Get middleware
 * @returns {*}
 */
BrowserSync.prototype.getMiddleware = function (type) {

    var types = {
        "connector": connectUtils.socketConnector(this.options),
        "socket-js": require("./snippet").utils.getSocketScript()
    };

    if (type in types) {
        return function (req, res) {
            res.setHeader("Content-Type", "text/javascript");
            res.end(types[type]);
        };
    }
};

/**
 * Shortcut for pushing a file-serving middleware
 * onto the stack
 * @param {String} path
 * @param {{type: string, content: string}} props
 */
var _serveFileCount = 0;
BrowserSync.prototype.serveFile = function (path, props) {

    var bs = this;
    var mode = bs.options.get("mode");
    var entry = {
        handle: function (req, res) {
            res.setHeader("Content-Type", props.type);
            res.end(props.content);
        },
        id: "Browsersync - " + _serveFileCount++,
        route: path
    };

    bs._addMiddlewareToStack(entry);
};

/**
 * Add middlewares on the fly
 * @param {{route: string, handle: function, id?: string}}
 */
BrowserSync.prototype._addMiddlewareToStack = function (entry) {
    var bs = this;
    if (bs.options.get("mode") === "proxy") {
        bs.app.stack.splice(bs.app.stack.length-1, 0, entry);
    } else {
        bs.app.stack.push(entry);
    }
};

var _addMiddlewareCount = 0;
BrowserSync.prototype.addMiddleware = function (route, handle, opts) {

    var bs   = this;

    if (!bs.app) {
        return;
    }

    opts = opts || {};

    if (!opts.id) {
        opts.id = "bs-mw-" + _addMiddlewareCount++;
    }

    if (route === "*") {
        route = "";
    }

    var entry = {
        id: opts.id,
        route: route,
        handle: handle
    };

    if (opts.override) {
        entry.override = true;
    }

    bs.options = bs.options.update("middleware", function (mw) {
        if (bs.options.get("mode") === "proxy") {
            return mw.insert(mw.size-1, entry);
        }
        return mw.concat(entry);
    });

    bs.resetMiddlewareStack();
};

/**
 * Remove middlewares on the fly
 * @param {String} id
 * @returns {Server}
 */
BrowserSync.prototype.removeMiddleware = function (id) {

    var bs = this;

    if (!bs.app) {
        return;
    }

    bs.options = bs.options.update("middleware", function (mw) {
        return mw.filter(function (mw) {
            return mw.id !== id;
        });
    });

    bs.resetMiddlewareStack();
};

/**
 * Middleware for socket connection (external usage)
 * @param opts
 * @returns {*}
 */
BrowserSync.prototype.getSocketConnector = function (opts) {

    var bs = this;

    return function (req, res) {
        res.setHeader("Content-Type", "text/javascript");
        res.end(bs.getExternalSocketConnector(opts));
    };
};

/**
 * Socket connector as a string
 * @param {Object} opts
 * @returns {*}
 */
BrowserSync.prototype.getExternalSocketConnector = function (opts) {

    var bs = this;

    return connectUtils.socketConnector(
        bs.options.withMutations(function (item) {
            item.set("socket", item.get("socket").merge(opts));
            if (!bs.options.getIn(["proxy", "ws"])) {
                item.set("mode", "snippet");
            }
        })
    );
};

/**
 * Socket io as string (for embedding)
 * @returns {*}
 */
BrowserSync.prototype.getSocketIoScript = function () {

    return require("./snippet").utils.getSocketScript();
};

/**
 * Callback helper
 * @param name
 */
BrowserSync.prototype.getOption = function (name) {

    this.debug("Getting option: {magenta:%s", name);
    return this.options.get(name);
};

/**
 * Callback helper
 * @param path
 */
BrowserSync.prototype.getOptionIn = function (path) {

    this.debug("Getting option via path: {magenta:%s", path);
    return this.options.getIn(path);
};

/**
 * @returns {BrowserSync.options}
 */
BrowserSync.prototype.getOptions = function () {
    return this.options;
};

/**
 * @returns {BrowserSync.options}
 */
BrowserSync.prototype.getLogger = logger.getLogger;

/**
 * @param {String} name
 * @param {*} value
 * @returns {BrowserSync.options|*}
 */
BrowserSync.prototype.setOption = function (name, value, opts) {

    var bs = this;

    opts = opts || {};

    bs.debug("Setting Option: {cyan:%s} - {magenta:%s", name, value.toString());

    bs.options = bs.options.set(name, value);

    if (!opts.silent) {
        bs.events.emit("options:set", {path: name, value: value, options: bs.options});
    }
    return this.options;
};

/**
 * @param path
 * @param value
 * @param opts
 * @returns {Map|*|BrowserSync.options}
 */
BrowserSync.prototype.setOptionIn = function (path, value, opts) {

    var bs = this;

    opts = opts || {};

    bs.debug("Setting Option: {cyan:%s} - {magenta:%s", path.join("."), value.toString());
    bs.options = bs.options.setIn(path, value);
    if (!opts.silent) {
        bs.events.emit("options:set", {path: path, value: value, options: bs.options});
    }
    return bs.options;
};

/**
 * Set multiple options with mutations
 * @param fn
 * @param opts
 * @returns {Map|*}
 */
BrowserSync.prototype.setMany = function (fn, opts) {

    var bs = this;

    opts = opts || {};

    bs.debug("Setting multiple Options");
    bs.options = bs.options.withMutations(fn);
    if (!opts.silent) {
        bs.events.emit("options:set", {options: bs.options.toJS()});
    }
    return this.options;
};

BrowserSync.prototype.addRewriteRule = function (rule) {
    var bs = this;

    bs.options = bs.options.update("rewriteRules", function (rules) {
        return rules.concat(rule);
    });

    bs.resetMiddlewareStack();
};

BrowserSync.prototype.removeRewriteRule = function (id) {
    var bs = this;
    bs.options = bs.options.update("rewriteRules", function (rules) {
        return rules.filter(function (rule) {
            return rule.id !== id;
        });
    });

    bs.resetMiddlewareStack();
};

BrowserSync.prototype.setRewriteRules = function (rules) {
    var bs = this;
    bs.options = bs.options.update("rewriteRules", function (_) {
        return rules;
    });

    bs.resetMiddlewareStack();
};

/**
 * Add a new rewrite rule to the stack
 * @param {Object} rule
 */
BrowserSync.prototype.resetMiddlewareStack = function () {

    var bs = this;
    var middlewares = require("./server/utils").getMiddlewares(bs, bs.options);

    bs.app.stack = middlewares;
};

/**
 * @param fn
 */
BrowserSync.prototype.registerCleanupTask = function (fn) {

    this._cleanupTasks.push(fn);
};

/**
 * Instance Cleanup
 */
BrowserSync.prototype.cleanup = function (cb) {

    var bs = this;
    if (!bs.active) {
        return;
    }

    // Remove all event listeners
    if (bs.events) {
        bs.debug("Removing event listeners...");
        bs.events.removeAllListeners();
    }

    // Close any core file watchers
    if (bs.watchers) {
        Object.keys(bs.watchers).forEach(function (key) {
            bs.watchers[key].watchers.forEach(function (watcher) {
                watcher.close();
            });
        });
    }

    // Run any additional clean up tasks
    bs._cleanupTasks.forEach(function (fn) {
        if (_.isFunction(fn)) {
            fn(bs);
        }
    });

    // Reset the flag
    bs.debug("Setting {magenta:active: false");
    bs.active = false;
    bs.paused = false;

    bs.pluginManager.plugins        = {};
    bs.pluginManager.pluginOptions  = {};
    bs.pluginManager.defaultPlugins = defaultPlugins;

    bs._userPlugins                = [];
    bs.userPlugins                 = [];
    bs._reloadTimer                = undefined;
    bs._reloadQueue                = [];
    bs._cleanupTasks               = [];

    if (_.isFunction(cb)) {
        cb(null, bs);
    }
};

module.exports = BrowserSync;
