import { reflect, string } from '@amp-metrics/mt-metricskit-utils-private';

/*
 *  src/utils.js
 *  mt-client-logger-core
 *
 *  Copyright © 2016-2017 Apple Inc. All rights reserved.
 *
 */

function FlagSymbol(key) {
    this.key = key;
}
FlagSymbol.prototype.toString = function toString() {
    return this.key;
};

var utils = {
    /**
     ************************************ PUBLIC METHODS/IVARS ************************************
     */

    /** Special flag classes that can be passed as arguments to logger methods in order to dictate logging behavior
     *  Use class instances to guarantee that flag arguments are unique, and use constructor names for O(1) lookup */
    flagArguments: {
        /**
         * When logging, if any of the arguments is an instance of this class, the log output will include a call stack trace.
         * @example usage: logger.warn('danger!', logger.INCLUDE_CALL_STACK);
         */
        INCLUDE_CALL_STACK: new FlagSymbol('INCLUDE_CALL_STACK'),
        /**
         * When logging, if any of the arguments is an instance of this class, the remaining arguments will be mirrored to the logging server
         * @example usage: logger.info('some message', logger.MIRROR_TO_SERVER);
         */
        MIRROR_TO_SERVER: new FlagSymbol('MIRROR_TO_SERVER'),
        /**
         * When logging, if any of the arguments is an instance of this class, the client (console) output will be suppressed
         * This would typically be used when callers want to log an event to the server without printing it
         * @example usage: logger.debug(someDiagnosticsInfoObject, logger.MIRROR_TO_SERVER, logger.SUPPRESS_CLIENT_OUTPUT);
         */
        SUPPRESS_CLIENT_OUTPUT: new FlagSymbol('SUPPRESS_CLIENT_OUTPUT')
    },

    /**
     * Allows replacement of one or more of this class' functions
     * Any method on the passed-in object which matches a method that this class has will be called instead of the built-in class method.
     * To replace *all* methods of his class, simply have your delegate implement all the methods of this class
     * Your delegate can be a true object instance, an anonymous object, or a class object.
     * Your delegate is free to have as many additional non-matching methods as it likes (these methods will not be copied to the target object).
     * It can even act as a delegate for multiple MetricsKit objects, though that is not recommended.
     *
     * "setDelegate()" may be called repeatedly, with the functions in the most-recently set delegates replacing any functions matching those in the earlier delegates, as well as any as-yet unreplaced functions.
     * This allows callers to replace some number of methods that need custom implementations.
     * If, for example, a client wants to use the standard logger implementation with the exception of, say, the "debug" method, they can
     * call "setDelegate()" with their own delegate containing only a single method of "debug" as the delegate, which would leave all the other methods intact.
     *
     * NOTE: The delegate function will have a property called origFunction representing the original function that it replaced.
     * This allows the delegate to, essentially, call "super" before or after it does some work.
     * If a replaced method is overridden again with a subsequent "setDelegate()" call, the "origFunction" property will be the previous delegate's function.
     * @example:
     * To override one or more methods, in place:
     *      logger.setDelegate({ debug: console.debug });
     * To override one or more methods with a separate object:
     *      logger.setDelegate(customLoggerDelegate);
     *      (where "customLoggerDelegate" might be defined elsewhere as, e.g.:
     *          var customLoggerDelegate = { debug: function(msg) { document.getElementById('debugMsg').innerHTML = msg; },
     *                                       serverUrl: function() { return 'https://custom-log-server.apple.com'; } };
     * To override one or more methods with an instantiated object from a class definition:
     *      eventRecorder.setDelegate(new CustomLoggerDelegate());
     *      (where "CustomLoggerDelegate" might be defined elsewhere as, e.g.:
     *          function CustomLoggerDelegate() {
     *         }
     *         CustomLoggerDelegate.prototype.debug = function debug(msg) {
     *                                                    document.getElementById('debugMsg').innerHTML = msg;
     *                                                };
     *         CustomLoggerDelegate.prototype.serverUrl = function serverUrl() {
     *                                                          return 'https://custom-log-server.apple.com';
     *                                                    };
     * To override one or more methods with a class object (with "static" methods):
     *      eventRecorder.setDelegate(CustomLoggerDelegate);
     *      (where "CustomLoggerDelegate" might be defined elsewhere as, e.g.:
     *          function CustomLoggerDelegate() {
     *         }
     *         CustomLoggerDelegate.debug = function debug(msg) {
     *                                          document.getElementById('debugMsg').innerHTML = msg;
     *                                      };
     *         CustomLoggerDelegate.serverUrl = function serverUrl() {
     *                                                return 'https://custom-log-server.apple.com';
     *                                          };
     * @param {Object} delegate Object or Class with delegate method(s) to be called instead of default (built-in) methods.
     * @returns {Boolean} true if one or more methods on the delegate object match one or more methods on the default object,
     * otherwise returns false.
     */
    setDelegate: function setDelegate(delegate) {
        return reflect.attachDelegate(this, delegate);
    },

    /**
     * If the log level allows, logs/throws an error to the console and mirrors the log event to the server
     * @param {Logger} logger
     * @param {String} methodName
     * @param {Array-like Object} origArguments
     */
    execute: function execute(logger, methodName, origArguments) {
        var methodLevel = logger.levelStringToIntMap[methodName];

        if (logger.level() !== logger.NONE && logger.level() <= methodLevel) {
            var argumentsArray = Array.prototype.slice.call(origArguments);
            var logArguments = utils.nonFlagLogArguments(argumentsArray);
            var logOptions = utils.logOptions(logger, methodLevel, argumentsArray);
            var callstack = logOptions.includeCallStack ? new Error().stack : null;
            var enrichedLogArguments = callstack ? logArguments.concat('\n' + callstack) : logArguments; // add newline for nicer output

            // so testing harness can verify logging done within tested functions:
            logger[methodName]._lastLog = enrichedLogArguments;

            if (logOptions.mirrorToServer) {
                utils.sendToServer(logger, methodName, logArguments, callstack);
            }

            if (logOptions.throwInsteadOfPrint) {
                throw new Error(logArguments.toString());
            } else if (!logOptions.suppressClientOutput) {
                if (console[methodName]) {
                    console[methodName].apply(console, enrichedLogArguments);
                } else {
                    // fallback to console.log - node does not have console.debug
                    console.log.apply(console, enrichedLogArguments);
                }
            }
        }
    },

    /**
     * Indicates whether an item is a specific flag object that dictates logging behavior
     * @param {*} argument
     * @return {Boolean}
     */
    isFlagObject: function isFlagObject(argument) {
        return argument && argument === utils.flagArguments[argument.toString()];
    },

    /**
     * Creates a new array without specific arguments that dictate logging behavior (and are not intended to be logged)
     * @param {Array} argumentsArray
     * @return {Array}
     */
    nonFlagLogArguments: function nonFlagLogArguments(argumentsArray) {
        return argumentsArray.filter(function (argument) {
            return !utils.isFlagObject(argument);
        });
    },

    /**
     * Inspects an array of arguments for specific flag objects that dictate log behavior and returns an object representing the intended behavior
     * By checking for all of the various flags in one pass, we avoid looping over the arguments array more than necessary
     * @param {Logger} logger
     * @param {Int} methodLevel
     * @param {Array} argumentsArray
     * @return {Object}
     */
    logOptions: function logOptions(logger, methodLevel, argumentsArray) {
        var logOptions = {};
        var optionName;

        argumentsArray.forEach(function (argument) {
            if (utils.isFlagObject(argument)) {
                optionName = string.snakeCaseToCamelCase(argument.toString());
                logOptions[optionName] = true;
            }
        });

        if (
            reflect.isFunction(logger.mirrorToServerLevel) &&
            logger.mirrorToServerLevel() !== logger.NONE &&
            logger.mirrorToServerLevel() <= methodLevel
        ) {
            logOptions.mirrorToServer = true;
        }
        if (logger.throwLevel() !== logger.NONE && logger.throwLevel() <= methodLevel) {
            logOptions.throwInsteadOfPrint = true;
        }

        return logOptions;
    },

    /**
     * Sends a log event to the server immediately without checking resolution
     * TODO: refactor to use eventRecorder once it is a standalone package
     * NO DEFAULT IMPLEMENTATION... THIS METHOD MUST BE REPLACED
     * @param {Logger} logger
     * @param {String} level
     * @param {Array} logArguments
     * @param {String} (optional) callstack
     * @return {String} the JSON-stringified event that was sent to the server
     * @overridable
     */
    sendToServer: function sendToServer(logger, level, logArguments, callstack) {}
};

/*
 *  src/logger.js
 *  mt-client-logger-core
 *
 *  Copyright © 2016-2017 Apple Inc. All rights reserved.
 *
 */

/**
 ************************************ PRIVATE METHODS/IVARS ************************************
 */

// Define log levels separately to expose this constant.
// TODO clean constants up when consolidate.
var LOG_LEVELS = {
    NONE: 0,
    DEBUG: 1,
    INFO: 2,
    WARN: 3,
    ERROR: 4
};
var LOGGER_LEVELS = {
    MIN_LEVEL: LOG_LEVELS.NONE,
    MAX_LEVEL: LOG_LEVELS.ERROR,
    levelIntToStringMap: {
        0: 'none',
        1: 'debug',
        2: 'info',
        3: 'warn',
        4: 'error'
    },
    levelStringToIntMap: {
        none: 0,
        debug: 1,
        info: 2,
        warn: 3,
        error: 4
    }
};

reflect.extend(LOGGER_LEVELS, LOG_LEVELS);

/** Global properties */
var LOGGER_PROPERTIES = {
    loggerName: 'defaultLogger',
    level: LOGGER_LEVELS.INFO,
    throwLevel: LOGGER_LEVELS.NONE
};

var _initialized = false;

/** A map of logger names to Logger instances */
var _loggers = {};

/**
 * Provides basic "log4j" type functionality.
 * The functionality in this class is typically replaced via a delegate.
 * NOTE: This class has a "secret" field extending each logger function called "_lastLog" which allows us to inspect logged errors from within our test cases of various functionality
 *       to ensure that the correct errors are thrown.
 * DEFAULT implementation: console logging
 * DEFAULT logger level: INFO
 * @see setDelegate
 * @delegatable
 * @constructor
 * @param {String} loggerName
 */
function Logger(loggerName) {
    // @private
    this._loggerName = loggerName;

    /* These variables are enumerated here for clarity */
    // @private
    this._level;
    // @private
    this._throwLevel;

    // lazily add prototype properties
    if (!_initialized) {
        _initialized = true;
        reflect.extend(Logger.prototype, LOGGER_LEVELS);
        reflect.extend(Logger.prototype, utils.flagArguments);
    }
}

/**
 * Returns the logger instance that has the name <loggerName>, creating a new one if it doesn't exist
 * @param {String} loggerName
 * @return {Logger}
 */
function loggerNamed(loggerName) {
    loggerName = loggerName || LOGGER_PROPERTIES.loggerName;
    var returnLogger = _loggers[loggerName];

    if (!returnLogger) {
        returnLogger = new Logger(loggerName);
        _loggers[loggerName] = returnLogger;
    }

    return returnLogger;
}

/**
 * Remove a logger from the cache
 * @param loggerName
 */
function removeLogger(loggerName) {
    if (_loggers) {
        delete _loggers[loggerName];
    }
}

function resetLoggerCache() {
    _loggers = {};
}

/** Default class property setters and getters */
Logger.level = function level() {
    return LOGGER_PROPERTIES.level;
};
Logger.throwLevel = function throwLevel() {
    return LOGGER_PROPERTIES.throwLevel;
};

// TODO: new PR with this, flesh out and make app-wide with docs
// Logger.setDelegate = function setDelegate() { };
// Logger.logCallback = function logCallback() { };

/**
 ************************************ PUBLIC METHODS/IVARS ************************************
 */

/**
 * Allows replacement of one or more of this class instance's functions
 * Any method on the passed-in object which matches a method that this class has will be called instead of the built-in class method.
 * To replace *all* methods of his class, simply have your delegate implement all the methods of this class
 * Your delegate can be a true object instance, an anonymous object, or a class object.
 * Your delegate is free to have as many additional non-matching methods as it likes (these methods will not be copied to the target object).
 * It can even act as a delegate for multiple MetricsKit objects, though that is not recommended.
 *
 * "setDelegate()" may be called repeatedly, with the functions in the most-recently set delegates replacing any functions matching those in the earlier delegates, as well as any as-yet unreplaced functions.
 * This allows callers to replace some number of methods that need custom implementations.
 * If, for example, a client wants to use the standard logger implementation with the exception of, say, the "debug" method, they can
 * call "setDelegate()" with their own delegate containing only a single method of "debug" as the delegate, which would leave all the other methods intact.
 *
 * NOTE: The delegate function will have a property called origFunction representing the original function that it replaced.
 * This allows the delegate to, essentially, call "super" before or after it does some work.
 * If a replaced method is overridden again with a subsequent "setDelegate()" call, the "origFunction" property will be the previous delegate's function.
 * @example:
 * To override one or more methods, in place:
 *      logger.setDelegate({ debug: console.debug });
 * To override one or more methods with a separate object:
 *      logger.setDelegate(customLoggerDelegate);
 *      (where "customLoggerDelegate" might be defined elsewhere as, e.g.:
 *          var customLoggerDelegate = { debug: function(msg) { document.getElementById('debugMsg').innerHTML = msg; },
 *                                       serverUrl: function() { return 'https://custom-log-server.apple.com'; } };
 * To override one or more methods with an instantiated object from a class definition:
 *      eventRecorder.setDelegate(new CustomLoggerDelegate());
 *      (where "CustomLoggerDelegate" might be defined elsewhere as, e.g.:
 *          function CustomLoggerDelegate() {
 *         }
 *         CustomLoggerDelegate.prototype.debug = function debug(msg) {
 *                                                    document.getElementById('debugMsg').innerHTML = msg;
 *                                                };
 *         CustomLoggerDelegate.prototype.serverUrl = function serverUrl() {
 *                                                          return 'https://custom-log-server.apple.com';
 *                                                    };
 * To override one or more methods with a class object (with "static" methods):
 *      eventRecorder.setDelegate(CustomLoggerDelegate);
 *      (where "CustomLoggerDelegate" might be defined elsewhere as, e.g.:
 *          function CustomLoggerDelegate() {
 *         }
 *         CustomLoggerDelegate.debug = function debug(msg) {
 *                                          document.getElementById('debugMsg').innerHTML = msg;
 *                                      };
 *         CustomLoggerDelegate.serverUrl = function serverUrl() {
 *                                                return 'https://custom-log-server.apple.com';
 *                                          };
 * @param {Object} delegate Object or Class with delegate method(s) to be called instead of default (built-in) methods.
 * @returns {Boolean} true if one or more methods on the delegate object match one or more methods on the default object,
 * otherwise returns false.
 */
Logger.prototype.setDelegate = function setDelegate(delegate) {
    return reflect.attachDelegate(this, delegate);
};

/**
 * The name of this logger
 * @returns {String}
 * @overridable
 */
Logger.prototype.loggerName = function loggerName() {
    return this._loggerName;
};

/**
 * Deduces the integer level from either a string or integer
 * @param {*} level loglevel which may be either a string (e.g. 'debug', 'DEBUG', 'Debug', etc.) or an integer (e.g. 1, 2, 3 or logger.DEBUG, logger.INFO, logger.WARN, etc.
 * @return {Int} the level as an integer or null if an invalid level argument was passed
 * @overrideable
 */
Logger.prototype.levelParameterAsInt = function levelParameterAsInt(level) {
    var returnLevel = null;
    var integerLevel;

    if (reflect.isString(level)) {
        integerLevel = this.levelStringToIntMap[level.toLowerCase()];
    } else if (reflect.isNumber(level)) {
        integerLevel = level;
    }

    if (integerLevel >= this.MIN_LEVEL && integerLevel <= this.MAX_LEVEL) {
        returnLevel = integerLevel;
    }

    return returnLevel;
};

/**
 * Sets the level at which we will log at or above
 * @param {*} level loglevel which may be either a string (e.g. 'debug', 'DEBUG', 'Debug', etc.) or an integer (e.g. 1, 2, 3 or logger.DEBUG, logger.INFO, logger.WARN, etc.
 * @overridable
 */
Logger.prototype.setLevel = function setLevel(level) {
    var integerLevel = this.levelParameterAsInt(level);
    if (integerLevel !== null) {
        this._level = integerLevel;
    }
};

/**
 * NOTE: This setting should be honored by all delegates.
 * This setting will cause any emitted log message at or above the specified level to throw an exception with the log message instead of logging to the console.
 * This is useful during testcase execution when we would expect to have no log output, or perhaps only "info" log output, etc.
 * @param {*} throwLevel loglevel which may be either a string (e.g. 'debug', 'DEBUG', 'Debug', etc.) or an integer (e.g. 1, 2, 3 or logger.DEBUG, logger.INFO, logger.WARN, etc.
 */
Logger.prototype.setThrowLevel = function setThrowLevel(throwLevel) {
    var integerLevel = this.levelParameterAsInt(throwLevel);
    if (integerLevel !== null) {
        this._throwLevel = integerLevel;
    }
};

/**
 * Returns the current logger level as an integer
 * @overridable
 */
Logger.prototype.level = function level() {
    var level = this._level;
    return reflect.isNumber(level) ? level : Logger.level();
};

/**
 * Returns the current logger level as a string
 * @overridable
 */
Logger.prototype.levelString = function levelString() {
    return this.levelIntToStringMap[this.level()];
};

/**
 * Returns the current logger throw level as an integer
 * @overridable
 */
Logger.prototype.throwLevel = function throwLevel() {
    var throwLevel = this._throwLevel;
    return reflect.isNumber(throwLevel) ? throwLevel : Logger.throwLevel();
};

/**
 * Emits the log message if log level is set to "debug".
 * DEFAULT implementation: console.debug()
 * @param {Object} a list of objects (perhaps a single string) to be stringified and emitted as the log message
 * @api public
 * @overridable
 */
Logger.prototype.debug = function debug() {
    utils.execute(this, 'debug', arguments);
};

/**
 * Emits the log message if log level is set to "info".
 * DEFAULT implementation: console.info()
 * @param {Object} a list of objects (perhaps a single string) to be stringified and emitted as the log message
 * @api public
 * @overridable
 */
Logger.prototype.info = function info() {
    utils.execute(this, 'info', arguments);
};

/**
 * Emits the log message if log level is set to "warn".
 * DEFAULT implementation: console.warn()
 * @param {Object} a list of objects (perhaps a single string) to be stringified and emitted as the log message
 * @api public
 * @overridable
 */
Logger.prototype.warn = function warn() {
    utils.execute(this, 'warn', arguments);
};

/**
 * Emits the log message if log level is set to "error".
 * DEFAULT implementation: console.error()
 * @param {Object} a list of objects (perhaps a single string) to be stringified and emitted as the log message
 * @api public
 * @overridable
 */
Logger.prototype.error = function error() {
    utils.execute(this, 'error', arguments);
};

/**
 * @param {String} levelString
 * @return {String} the most recent log event for this level
 */
Logger.prototype.lastLog = function lastLog(levelString) {
    return this[levelString] ? this[levelString]._lastLog : null;
};

var level = Logger.level;
var throwLevel = Logger.throwLevel;

/*
 *  mt-client-logger-core/index.js
 *  mt-client-logger-core
 *
 *  Copyright © 2016-2017 Apple Inc. All rights reserved.
 *
 */

export default Logger;
export { LOG_LEVELS, level, loggerNamed, removeLogger, resetLoggerCache, throwLevel, utils };
