'use strict';

var typeName = require('type-name');
var forEach = require('core-js/library/fn/array/for-each');
var arrayFilter = require('core-js/library/fn/array/filter');
var reduceRight = require('core-js/library/fn/array/reduce-right');
var indexOf = require('core-js/library/fn/array/index-of');
var slice = Array.prototype.slice;
var END = {};
var ITERATE = {};

// arguments should end with end or iterate
function compose () {
    var filters = slice.apply(arguments);
    return reduceRight(filters, function(right, left) {
        return left(right);
    });
}

// skip children
function end () {
    return function (acc, x) {
        acc.context.keys = [];
        return END;
    };
}

// iterate children
function iterate () {
    return function (acc, x) {
        return ITERATE;
    };
}

function filter (predicate) {
    return function (next) {
        return function (acc, x) {
            var toBeIterated;
            var isIteratingArray = (typeName(x) === 'Array');
            if (typeName(predicate) === 'function') {
                toBeIterated = [];
                forEach(acc.context.keys, function (key) {
                    var indexOrKey = isIteratingArray ? parseInt(key, 10) : key;
                    var kvp = {
                        key: indexOrKey,
                        value: x[key]
                    };
                    var decision = predicate(kvp);
                    if (decision) {
                        toBeIterated.push(key);
                    }
                    if (typeName(decision) === 'number') {
                        truncateByKey(decision, key, acc);
                    }
                    if (typeName(decision) === 'function') {
                        customizeStrategyForKey(decision, key, acc);
                    }
                });
                acc.context.keys = toBeIterated;
            }
            return next(acc, x);
        };
    };
}

function customizeStrategyForKey (strategy, key, acc) {
    acc.handlers[currentPath(key, acc)] = strategy;
}

function truncateByKey (size, key, acc) {
    acc.handlers[currentPath(key, acc)] = size;
}

function currentPath (key, acc) {
    var pathToCurrentNode = [''].concat(acc.context.path);
    if (typeName(key) !== 'undefined') {
        pathToCurrentNode.push(key);
    }
    return pathToCurrentNode.join('/');
}

function allowedKeys (orderedWhiteList) {
    return function (next) {
        return function (acc, x) {
            var isIteratingArray = (typeName(x) === 'Array');
            if (!isIteratingArray && typeName(orderedWhiteList) === 'Array') {
                acc.context.keys = arrayFilter(orderedWhiteList, function (propKey) {
                    return x.hasOwnProperty(propKey);
                });
            }
            return next(acc, x);
        };
    };
}

function safeKeys () {
    return function (next) {
        return function (acc, x) {
            if (typeName(x) !== 'Array') {
                acc.context.keys = arrayFilter(acc.context.keys, function (propKey) {
                    // Error handling for unsafe property access.
                    // For example, on PhantomJS,
                    // accessing HTMLInputElement.selectionEnd causes TypeError
                    try {
                        var val = x[propKey];
                        return true;
                    } catch (e) {
                        // skip unsafe key
                        return false;
                    }
                });
            }
            return next(acc, x);
        };
    };
}

function arrayIndicesToKeys () {
    return function (next) {
        return function (acc, x) {
            if (typeName(x) === 'Array' && 0 < x.length) {
                var indices = Array(x.length);
                for(var i = 0; i < x.length; i += 1) {
                    indices[i] = String(i); // traverse uses strings as keys
                }
                acc.context.keys = indices;
            }
            return next(acc, x);
        };
    };
}

function when (guard, then) {
    return function (next) {
        return function (acc, x) {
            var kvp = {
                key: acc.context.key,
                value: x
            };
            if (guard(kvp, acc)) {
                return then(acc, x);
            }
            return next(acc, x);
        };
    };
}

function truncate (size) {
    return function (next) {
        return function (acc, x) {
            var orig = acc.push;
            var ret;
            acc.push = function (str) {
                var savings = str.length - size;
                var truncated;
                if (savings <= size) {
                    orig.call(acc, str);
                } else {
                    truncated = str.substring(0, size);
                    orig.call(acc, truncated + acc.options.snip);
                }
            };
            ret = next(acc, x);
            acc.push = orig;
            return ret;
        };
    };
}

function constructorName () {
    return function (next) {
        return function (acc, x) {
            var name = acc.options.typeFun(x);
            if (name === '') {
                name = acc.options.anonymous;
            }
            acc.push(name);
            return next(acc, x);
        };
    };
}

function always (str) {
    return function (next) {
        return function (acc, x) {
            acc.push(str);
            return next(acc, x);
        };
    };
}

function optionValue (key) {
    return function (next) {
        return function (acc, x) {
            acc.push(acc.options[key]);
            return next(acc, x);
        };
    };
}

function json (replacer) {
    return function (next) {
        return function (acc, x) {
            acc.push(JSON.stringify(x, replacer));
            return next(acc, x);
        };
    };
}

function toStr () {
    return function (next) {
        return function (acc, x) {
            acc.push(x.toString());
            return next(acc, x);
        };
    };
}

function decorateArray () {
    return function (next) {
        return function (acc, x) {
            acc.context.before(function (node) {
                acc.push('[');
            });
            acc.context.after(function (node) {
                afterAllChildren(this, acc.push, acc.options);
                acc.push(']');
            });
            acc.context.pre(function (val, key) {
                beforeEachChild(this, acc.push, acc.options);
            });
            acc.context.post(function (childContext) {
                afterEachChild(childContext, acc.push);
            });
            return next(acc, x);
        };
    };
}

function decorateObject () {
    return function (next) {
        return function (acc, x) {
            acc.context.before(function (node) {
                acc.push('{');
            });
            acc.context.after(function (node) {
                afterAllChildren(this, acc.push, acc.options);
                acc.push('}');
            });
            acc.context.pre(function (val, key) {
                beforeEachChild(this, acc.push, acc.options);
                acc.push(sanitizeKey(key) + (acc.options.indent ? ': ' : ':'));
            });
            acc.context.post(function (childContext) {
                afterEachChild(childContext, acc.push);
            });
            return next(acc, x);
        };
    };
}

function sanitizeKey (key) {
    return /^[A-Za-z_]+$/.test(key) ? key : JSON.stringify(key);
}

function afterAllChildren (context, push, options) {
    if (options.indent && 0 < context.keys.length) {
        push(options.lineSeparator);
        for(var i = 0; i < context.level; i += 1) { // indent level - 1
            push(options.indent);
        }
    }
}

function beforeEachChild (context, push, options) {
    if (options.indent) {
        push(options.lineSeparator);
        for(var i = 0; i <= context.level; i += 1) {
            push(options.indent);
        }
    }
}

function afterEachChild (childContext, push) {
    if (!childContext.isLast) {
        push(',');
    }
}

function nan (kvp, acc) {
    return kvp.value !== kvp.value;
}

function positiveInfinity (kvp, acc) {
    return !isFinite(kvp.value) && kvp.value === Infinity;
}

function negativeInfinity (kvp, acc) {
    return !isFinite(kvp.value) && kvp.value !== Infinity;
}

function circular (kvp, acc) {
    return acc.context.circular;
}

function maxDepth (kvp, acc) {
    return (acc.options.maxDepth && acc.options.maxDepth <= acc.context.level);
}

var prune = compose(
    always('#'),
    constructorName(),
    always('#'),
    end()
);
var omitNaN = when(nan, compose(
    always('NaN'),
    end()
));
var omitPositiveInfinity = when(positiveInfinity, compose(
    always('Infinity'),
    end()
));
var omitNegativeInfinity = when(negativeInfinity, compose(
    always('-Infinity'),
    end()
));
var omitCircular = when(circular, compose(
    optionValue('circular'),
    end()
));
var omitMaxDepth = when(maxDepth, prune);

module.exports = {
    filters: {
        always: always,
        optionValue: optionValue,
        constructorName: constructorName,
        json: json,
        toStr: toStr,
        prune: prune,
        truncate: truncate,
        decorateArray: decorateArray,
        decorateObject: decorateObject
    },
    flow: {
        compose: compose,
        when: when,
        allowedKeys: allowedKeys,
        safeKeys: safeKeys,
        arrayIndicesToKeys: arrayIndicesToKeys,
        filter: filter,
        iterate: iterate,
        end: end
    },
    symbols: {
        END: END,
        ITERATE: ITERATE
    },
    always: function (str) {
        return compose(always(str), end());
    },
    json: function () {
        return compose(json(), end());
    },
    toStr: function () {
        return compose(toStr(), end());
    },
    prune: function () {
        return prune;
    },
    number: function () {
        return compose(
            omitNaN,
            omitPositiveInfinity,
            omitNegativeInfinity,
            json(),
            end()
        );
    },
    newLike: function () {
        return compose(
            always('new '),
            constructorName(),
            always('('),
            json(),
            always(')'),
            end()
        );
    },
    array: function (predicate) {
        return compose(
            omitCircular,
            omitMaxDepth,
            decorateArray(),
            arrayIndicesToKeys(),
            filter(predicate),
            iterate()
        );
    },
    object: function (predicate, orderedWhiteList) {
        return compose(
            omitCircular,
            omitMaxDepth,
            constructorName(),
            decorateObject(),
            allowedKeys(orderedWhiteList),
            safeKeys(),
            filter(predicate),
            iterate()
        );
    }
};
