/**
 * Module dependencies.
 */

var keys = require('./keys');
var hasBinary = require('has-binary2');
var sliceBuffer = require('arraybuffer.slice');
var after = require('after');
var utf8 = require('./utf8');

var base64encoder;
if (typeof ArrayBuffer !== 'undefined') {
    base64encoder = require('base64-arraybuffer');
}

/**
 * Check if we are running an android browser. That requires us to use
 * ArrayBuffer with polling transports...
 *
 * http://ghinda.net/jpeg-blob-ajax-android/
 */

var isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);

/**
 * Check if we are running in PhantomJS.
 * Uploading a Blob with PhantomJS does not work correctly, as reported here:
 * https://github.com/ariya/phantomjs/issues/11395
 * @type boolean
 */
var isPhantomJS = typeof navigator !== 'undefined' && /PhantomJS/i.test(navigator.userAgent);

/**
 * When true, avoids using Blobs to encode payloads.
 * @type boolean
 */
var dontSendBlobs = isAndroid || isPhantomJS;

/**
 * Current protocol version.
 */

exports.protocol = 3;

/**
 * Packet types.
 */

var packets = exports.packets = {
    open: 0 // non-ws
        ,
    close: 1 // non-ws
        ,
    ping: 2,
    pong: 3,
    message: 4,
    upgrade: 5,
    noop: 6
};

var packetslist = keys(packets);

/**
 * Premade error packet.
 */

var err = {
    type: 'error',
    data: 'parser error'
};

/**
 * Create a blob api even for blob builder when vendor prefixes exist
 */

var Blob = require('blob');

/**
 * Encodes a packet.
 *
 *     <packet type id> [ <data> ]
 *
 * Example:
 *
 *     5hello world
 *     3
 *     4
 *
 * Binary is encoded in an identical principle
 *
 * @api private
 */

exports.encodePacket = function(packet, supportsBinary, utf8encode, callback) {
    if (typeof supportsBinary === 'function') {
        callback = supportsBinary;
        supportsBinary = false;
    }

    if (typeof utf8encode === 'function') {
        callback = utf8encode;
        utf8encode = null;
    }

    var data = (packet.data === undefined) ?
        undefined :
        packet.data.buffer || packet.data;

    if (typeof ArrayBuffer !== 'undefined' && data instanceof ArrayBuffer) {
        return encodeArrayBuffer(packet, supportsBinary, callback);
    } else if (typeof Blob !== 'undefined' && data instanceof Blob) {
        return encodeBlob(packet, supportsBinary, callback);
    }

    // might be an object with { base64: true, data: dataAsBase64String }
    if (data && data.base64) {
        return encodeBase64Object(packet, callback);
    }

    // Sending data as a utf-8 string
    var encoded = packets[packet.type];

    // data fragment is optional
    if (undefined !== packet.data) {
        encoded += utf8encode ? utf8.encode(String(packet.data), {
            strict: false
        }) : String(packet.data);
    }

    return callback('' + encoded);

};

function encodeBase64Object(packet, callback) {
    // packet data is an object { base64: true, data: dataAsBase64String }
    var message = 'b' + exports.packets[packet.type] + packet.data.data;
    return callback(message);
}

/**
 * Encode packet helpers for binary types
 */

function encodeArrayBuffer(packet, supportsBinary, callback) {
    if (!supportsBinary) {
        return exports.encodeBase64Packet(packet, callback);
    }

    var data = packet.data;
    var contentArray = new Uint8Array(data);
    var resultBuffer = new Uint8Array(1 + data.byteLength);

    resultBuffer[0] = packets[packet.type];
    for (var i = 0; i < contentArray.length; i++) {
        resultBuffer[i + 1] = contentArray[i];
    }

    return callback(resultBuffer.buffer);
}

function encodeBlobAsArrayBuffer(packet, supportsBinary, callback) {
    if (!supportsBinary) {
        return exports.encodeBase64Packet(packet, callback);
    }

    var fr = new FileReader();
    fr.onload = function() {
        exports.encodePacket({
            type: packet.type,
            data: fr.result
        }, supportsBinary, true, callback);
    };
    return fr.readAsArrayBuffer(packet.data);
}

function encodeBlob(packet, supportsBinary, callback) {
    if (!supportsBinary) {
        return exports.encodeBase64Packet(packet, callback);
    }

    if (dontSendBlobs) {
        return encodeBlobAsArrayBuffer(packet, supportsBinary, callback);
    }

    var length = new Uint8Array(1);
    length[0] = packets[packet.type];
    var blob = new Blob([length.buffer, packet.data]);

    return callback(blob);
}

/**
 * Encodes a packet with binary data in a base64 string
 *
 * @param {Object} packet, has `type` and `data`
 * @return {String} base64 encoded message
 */

exports.encodeBase64Packet = function(packet, callback) {
    var message = 'b' + exports.packets[packet.type];
    if (typeof Blob !== 'undefined' && packet.data instanceof Blob) {
        var fr = new FileReader();
        fr.onload = function() {
            var b64 = fr.result.split(',')[1];
            callback(message + b64);
        };
        return fr.readAsDataURL(packet.data);
    }

    var b64data;
    try {
        b64data = String.fromCharCode.apply(null, new Uint8Array(packet.data));
    } catch (e) {
        // iPhone Safari doesn't let you apply with typed arrays
        var typed = new Uint8Array(packet.data);
        var basic = new Array(typed.length);
        for (var i = 0; i < typed.length; i++) {
            basic[i] = typed[i];
        }
        b64data = String.fromCharCode.apply(null, basic);
    }
    message += btoa(b64data);
    return callback(message);
};

/**
 * Decodes a packet. Changes format to Blob if requested.
 *
 * @return {Object} with `type` and `data` (if any)
 * @api private
 */

exports.decodePacket = function(data, binaryType, utf8decode) {
    if (data === undefined) {
        return err;
    }
    // String data
    if (typeof data === 'string') {
        if (data.charAt(0) === 'b') {
            return exports.decodeBase64Packet(data.substr(1), binaryType);
        }

        if (utf8decode) {
            data = tryDecode(data);
            if (data === false) {
                return err;
            }
        }
        var type = data.charAt(0);

        if (Number(type) != type || !packetslist[type]) {
            return err;
        }

        if (data.length > 1) {
            return {
                type: packetslist[type],
                data: data.substring(1)
            };
        } else {
            return {
                type: packetslist[type]
            };
        }
    }

    var asArray = new Uint8Array(data);
    var type = asArray[0];
    var rest = sliceBuffer(data, 1);
    if (Blob && binaryType === 'blob') {
        rest = new Blob([rest]);
    }
    return {
        type: packetslist[type],
        data: rest
    };
};

function tryDecode(data) {
    try {
        data = utf8.decode(data, {
            strict: false
        });
    } catch (e) {
        return false;
    }
    return data;
}

/**
 * Decodes a packet encoded in a base64 string
 *
 * @param {String} base64 encoded message
 * @return {Object} with `type` and `data` (if any)
 */

exports.decodeBase64Packet = function(msg, binaryType) {
    var type = packetslist[msg.charAt(0)];
    if (!base64encoder) {
        return {
            type: type,
            data: {
                base64: true,
                data: msg.substr(1)
            }
        };
    }

    var data = base64encoder.decode(msg.substr(1));

    if (binaryType === 'blob' && Blob) {
        data = new Blob([data]);
    }

    return {
        type: type,
        data: data
    };
};

/**
 * Encodes multiple messages (payload).
 *
 *     <length>:data
 *
 * Example:
 *
 *     11:hello world2:hi
 *
 * If any contents are binary, they will be encoded as base64 strings. Base64
 * encoded strings are marked with a b before the length specifier
 *
 * @param {Array} packets
 * @api private
 */

exports.encodePayload = function(packets, supportsBinary, callback) {
    if (typeof supportsBinary === 'function') {
        callback = supportsBinary;
        supportsBinary = null;
    }

    var isBinary = hasBinary(packets);

    if (supportsBinary && isBinary) {
        if (Blob && !dontSendBlobs) {
            return exports.encodePayloadAsBlob(packets, callback);
        }

        return exports.encodePayloadAsArrayBuffer(packets, callback);
    }

    if (!packets.length) {
        return callback('0:');
    }

    function setLengthHeader(message) {
        return message.length + ':' + message;
    }

    function encodeOne(packet, doneCallback) {
        exports.encodePacket(packet, !isBinary ? false : supportsBinary, false, function(message) {
            doneCallback(null, setLengthHeader(message));
        });
    }

    map(packets, encodeOne, function(err, results) {
        return callback(results.join(''));
    });
};

/**
 * Async array map using after
 */

function map(ary, each, done) {
    var result = new Array(ary.length);
    var next = after(ary.length, done);

    var eachWithIndex = function(i, el, cb) {
        each(el, function(error, msg) {
            result[i] = msg;
            cb(error, result);
        });
    };

    for (var i = 0; i < ary.length; i++) {
        eachWithIndex(i, ary[i], next);
    }
}

/*
 * Decodes data when a payload is maybe expected. Possible binary contents are
 * decoded from their base64 representation
 *
 * @param {String} data, callback method
 * @api public
 */

exports.decodePayload = function(data, binaryType, callback) {
    if (typeof data !== 'string') {
        return exports.decodePayloadAsBinary(data, binaryType, callback);
    }

    if (typeof binaryType === 'function') {
        callback = binaryType;
        binaryType = null;
    }

    var packet;
    if (data === '') {
        // parser error - ignoring payload
        return callback(err, 0, 1);
    }

    var length = '',
        n, msg;

    for (var i = 0, l = data.length; i < l; i++) {
        var chr = data.charAt(i);

        if (chr !== ':') {
            length += chr;
            continue;
        }

        if (length === '' || (length != (n = Number(length)))) {
            // parser error - ignoring payload
            return callback(err, 0, 1);
        }

        msg = data.substr(i + 1, n);

        if (length != msg.length) {
            // parser error - ignoring payload
            return callback(err, 0, 1);
        }

        if (msg.length) {
            packet = exports.decodePacket(msg, binaryType, false);

            if (err.type === packet.type && err.data === packet.data) {
                // parser error in individual packet - ignoring payload
                return callback(err, 0, 1);
            }

            var ret = callback(packet, i + n, l);
            if (false === ret) return;
        }

        // advance cursor
        i += n;
        length = '';
    }

    if (length !== '') {
        // parser error - ignoring payload
        return callback(err, 0, 1);
    }

};

/**
 * Encodes multiple messages (payload) as binary.
 *
 * <1 = binary, 0 = string><number from 0-9><number from 0-9>[...]<number
 * 255><data>
 *
 * Example:
 * 1 3 255 1 2 3, if the binary contents are interpreted as 8 bit integers
 *
 * @param {Array} packets
 * @return {ArrayBuffer} encoded payload
 * @api private
 */

exports.encodePayloadAsArrayBuffer = function(packets, callback) {
    if (!packets.length) {
        return callback(new ArrayBuffer(0));
    }

    function encodeOne(packet, doneCallback) {
        exports.encodePacket(packet, true, true, function(data) {
            return doneCallback(null, data);
        });
    }

    map(packets, encodeOne, function(err, encodedPackets) {
        var totalLength = encodedPackets.reduce(function(acc, p) {
            var len;
            if (typeof p === 'string') {
                len = p.length;
            } else {
                len = p.byteLength;
            }
            return acc + len.toString().length + len + 2; // string/binary identifier + separator = 2
        }, 0);

        var resultArray = new Uint8Array(totalLength);

        var bufferIndex = 0;
        encodedPackets.forEach(function(p) {
            var isString = typeof p === 'string';
            var ab = p;
            if (isString) {
                var view = new Uint8Array(p.length);
                for (var i = 0; i < p.length; i++) {
                    view[i] = p.charCodeAt(i);
                }
                ab = view.buffer;
            }

            if (isString) { // not true binary
                resultArray[bufferIndex++] = 0;
            } else { // true binary
                resultArray[bufferIndex++] = 1;
            }

            var lenStr = ab.byteLength.toString();
            for (var i = 0; i < lenStr.length; i++) {
                resultArray[bufferIndex++] = parseInt(lenStr[i]);
            }
            resultArray[bufferIndex++] = 255;

            var view = new Uint8Array(ab);
            for (var i = 0; i < view.length; i++) {
                resultArray[bufferIndex++] = view[i];
            }
        });

        return callback(resultArray.buffer);
    });
};

/**
 * Encode as Blob
 */

exports.encodePayloadAsBlob = function(packets, callback) {
    function encodeOne(packet, doneCallback) {
        exports.encodePacket(packet, true, true, function(encoded) {
            var binaryIdentifier = new Uint8Array(1);
            binaryIdentifier[0] = 1;
            if (typeof encoded === 'string') {
                var view = new Uint8Array(encoded.length);
                for (var i = 0; i < encoded.length; i++) {
                    view[i] = encoded.charCodeAt(i);
                }
                encoded = view.buffer;
                binaryIdentifier[0] = 0;
            }

            var len = (encoded instanceof ArrayBuffer) ?
                encoded.byteLength :
                encoded.size;

            var lenStr = len.toString();
            var lengthAry = new Uint8Array(lenStr.length + 1);
            for (var i = 0; i < lenStr.length; i++) {
                lengthAry[i] = parseInt(lenStr[i]);
            }
            lengthAry[lenStr.length] = 255;

            if (Blob) {
                var blob = new Blob([binaryIdentifier.buffer, lengthAry.buffer, encoded]);
                doneCallback(null, blob);
            }
        });
    }

    map(packets, encodeOne, function(err, results) {
        return callback(new Blob(results));
    });
};

/*
 * Decodes data when a payload is maybe expected. Strings are decoded by
 * interpreting each byte as a key code for entries marked to start with 0. See
 * description of encodePayloadAsBinary
 *
 * @param {ArrayBuffer} data, callback method
 * @api public
 */

exports.decodePayloadAsBinary = function(data, binaryType, callback) {
    if (typeof binaryType === 'function') {
        callback = binaryType;
        binaryType = null;
    }

    var bufferTail = data;
    var buffers = [];

    while (bufferTail.byteLength > 0) {
        var tailArray = new Uint8Array(bufferTail);
        var isString = tailArray[0] === 0;
        var msgLength = '';

        for (var i = 1;; i++) {
            if (tailArray[i] === 255) break;

            // 310 = char length of Number.MAX_VALUE
            if (msgLength.length > 310) {
                return callback(err, 0, 1);
            }

            msgLength += tailArray[i];
        }

        bufferTail = sliceBuffer(bufferTail, 2 + msgLength.length);
        msgLength = parseInt(msgLength);

        var msg = sliceBuffer(bufferTail, 0, msgLength);
        if (isString) {
            try {
                msg = String.fromCharCode.apply(null, new Uint8Array(msg));
            } catch (e) {
                // iPhone Safari doesn't let you apply to typed arrays
                var typed = new Uint8Array(msg);
                msg = '';
                for (var i = 0; i < typed.length; i++) {
                    msg += String.fromCharCode(typed[i]);
                }
            }
        }

        buffers.push(msg);
        bufferTail = sliceBuffer(bufferTail, msgLength);
    }

    var total = buffers.length;
    buffers.forEach(function(buffer, i) {
        callback(exports.decodePacket(buffer, binaryType, true), i, total);
    });
};