"use strict";

var TextEncoder = require("text-encoding").TextEncoder;

var configureLogError = require("../configure-logger");
var sinonEvent = require("../event");
var extend = require("just-extend");

function getWorkingXHR(globalScope) {
    var supportsXHR = typeof globalScope.XMLHttpRequest !== "undefined";
    if (supportsXHR) {
        return globalScope.XMLHttpRequest;
    }

    var supportsActiveX = typeof globalScope.ActiveXObject !== "undefined";
    if (supportsActiveX) {
        return function () {
            return new globalScope.ActiveXObject("MSXML2.XMLHTTP.3.0");
        };
    }

    return false;
}

var supportsProgress = typeof ProgressEvent !== "undefined";
var supportsCustomEvent = typeof CustomEvent !== "undefined";
var supportsFormData = typeof FormData !== "undefined";
var supportsArrayBuffer = typeof ArrayBuffer !== "undefined";
var supportsBlob = require("./blob").isSupported;
var isReactNative = global.navigator && global.navigator.product === "ReactNative";
var sinonXhr = { XMLHttpRequest: global.XMLHttpRequest };
sinonXhr.GlobalXMLHttpRequest = global.XMLHttpRequest;
sinonXhr.GlobalActiveXObject = global.ActiveXObject;
sinonXhr.supportsActiveX = typeof sinonXhr.GlobalActiveXObject !== "undefined";
sinonXhr.supportsXHR = typeof sinonXhr.GlobalXMLHttpRequest !== "undefined";
sinonXhr.workingXHR = getWorkingXHR(global);
sinonXhr.supportsTimeout =
    (sinonXhr.supportsXHR && "timeout" in (new sinonXhr.GlobalXMLHttpRequest()));
sinonXhr.supportsCORS = isReactNative ||
    (sinonXhr.supportsXHR && "withCredentials" in (new sinonXhr.GlobalXMLHttpRequest()));

// Ref: https://fetch.spec.whatwg.org/#forbidden-header-name
var unsafeHeaders = {
    "Accept-Charset": true,
    "Access-Control-Request-Headers": true,
    "Access-Control-Request-Method": true,
    "Accept-Encoding": true,
    "Connection": true,
    "Content-Length": true,
    "Cookie": true,
    "Cookie2": true,
    "Content-Transfer-Encoding": true,
    "Date": true,
    "DNT": true,
    "Expect": true,
    "Host": true,
    "Keep-Alive": true,
    "Origin": true,
    "Referer": true,
    "TE": true,
    "Trailer": true,
    "Transfer-Encoding": true,
    "Upgrade": true,
    "User-Agent": true,
    "Via": true
};


function EventTargetHandler() {
    var self = this;
    var events = ["loadstart", "progress", "abort", "error", "load", "timeout", "loadend"];

    function addEventListener(eventName) {
        self.addEventListener(eventName, function (event) {
            var listener = self["on" + eventName];

            if (listener && typeof listener === "function") {
                listener.call(this, event);
            }
        });
    }

    events.forEach(addEventListener);
}

EventTargetHandler.prototype = sinonEvent.EventTarget;

// Note that for FakeXMLHttpRequest to work pre ES5
// we lose some of the alignment with the spec.
// To ensure as close a match as possible,
// set responseType before calling open, send or respond;
function FakeXMLHttpRequest(config) {
    EventTargetHandler.call(this);
    this.readyState = FakeXMLHttpRequest.UNSENT;
    this.requestHeaders = {};
    this.requestBody = null;
    this.status = 0;
    this.statusText = "";
    this.upload = new EventTargetHandler();
    this.responseType = "";
    this.response = "";
    this.logError = configureLogError(config);

    if (sinonXhr.supportsTimeout) {
        this.timeout = 0;
    }

    if (sinonXhr.supportsCORS) {
        this.withCredentials = false;
    }

    if (typeof FakeXMLHttpRequest.onCreate === "function") {
        FakeXMLHttpRequest.onCreate(this);
    }
}

function verifyState(xhr) {
    if (xhr.readyState !== FakeXMLHttpRequest.OPENED) {
        throw new Error("INVALID_STATE_ERR");
    }

    if (xhr.sendFlag) {
        throw new Error("INVALID_STATE_ERR");
    }
}

function normalizeHeaderValue(value) {
    // Ref: https://fetch.spec.whatwg.org/#http-whitespace-bytes
    /*eslint no-control-regex: "off"*/
    return value.replace(/^[\x09\x0A\x0D\x20]+|[\x09\x0A\x0D\x20]+$/g, "");
}

function getHeader(headers, header) {
    var foundHeader = Object.keys(headers).filter(function (h) {
        return h.toLowerCase() === header.toLowerCase();
    });

    return foundHeader[0] || null;
}

function excludeSetCookie2Header(header) {
    return !/^Set-Cookie2?$/i.test(header);
}

// largest arity in XHR is 5 - XHR#open
var apply = function (obj, method, args) {
    switch (args.length) {
        case 0: return obj[method]();
        case 1: return obj[method](args[0]);
        case 2: return obj[method](args[0], args[1]);
        case 3: return obj[method](args[0], args[1], args[2]);
        case 4: return obj[method](args[0], args[1], args[2], args[3]);
        case 5: return obj[method](args[0], args[1], args[2], args[3], args[4]);
        default: throw new Error("Unhandled case");
    }
};

FakeXMLHttpRequest.filters = [];
FakeXMLHttpRequest.addFilter = function addFilter(fn) {
    this.filters.push(fn);
};
FakeXMLHttpRequest.defake = function defake(fakeXhr, xhrArgs) {
    var xhr = new sinonXhr.workingXHR(); // eslint-disable-line new-cap

    [
        "open",
        "setRequestHeader",
        "abort",
        "getResponseHeader",
        "getAllResponseHeaders",
        "addEventListener",
        "overrideMimeType",
        "removeEventListener"
    ].forEach(function (method) {
        fakeXhr[method] = function () {
            return apply(xhr, method, arguments);
        };
    });

    fakeXhr.send = function () {
        xhr.responseType = fakeXhr.responseType;
        return apply(xhr, "send", arguments);
    };

    var copyAttrs = function (args) {
        args.forEach(function (attr) {
            fakeXhr[attr] = xhr[attr];
        });
    };

    var stateChange = function stateChange() {
        fakeXhr.readyState = xhr.readyState;
        if (xhr.readyState >= FakeXMLHttpRequest.HEADERS_RECEIVED) {
            copyAttrs(["status", "statusText"]);
        }
        if (xhr.readyState >= FakeXMLHttpRequest.LOADING) {
            copyAttrs(["response"]);
            if (xhr.responseType === "" || xhr.responseType === "text") {
                copyAttrs(["responseText"]);
            }
        }
        if (
            xhr.readyState === FakeXMLHttpRequest.DONE &&
            (xhr.responseType === "" || xhr.responseType === "document")
        ) {
            copyAttrs(["responseXML"]);
        }
        if (fakeXhr.onreadystatechange) {
            fakeXhr.onreadystatechange.call(fakeXhr, { target: fakeXhr, currentTarget: fakeXhr });
        }
    };

    if (xhr.addEventListener) {
        Object.keys(fakeXhr.eventListeners).forEach(function (event) {
            /*eslint-disable no-loop-func*/
            fakeXhr.eventListeners[event].forEach(function (handler) {
                xhr.addEventListener(event, handler.listener, {
                    capture: handler.capture,
                    once: handler.once
                });
            });
            /*eslint-enable no-loop-func*/
        });

        xhr.addEventListener("readystatechange", stateChange);
    } else {
        xhr.onreadystatechange = stateChange;
    }
    apply(xhr, "open", xhrArgs);
};
FakeXMLHttpRequest.useFilters = false;

function verifyRequestOpened(xhr) {
    if (xhr.readyState !== FakeXMLHttpRequest.OPENED) {
        throw new Error("INVALID_STATE_ERR - " + xhr.readyState);
    }
}

function verifyRequestSent(xhr) {
    if (xhr.readyState === FakeXMLHttpRequest.DONE) {
        throw new Error("Request done");
    }
}

function verifyHeadersReceived(xhr) {
    if (xhr.async && xhr.readyState !== FakeXMLHttpRequest.HEADERS_RECEIVED) {
        throw new Error("No headers received");
    }
}

function verifyResponseBodyType(body, responseType) {
    var error = null;
    var isString = typeof body === "string";

    if (responseType === "arraybuffer") {

        if (!isString && !(body instanceof ArrayBuffer)) {
            error = new Error("Attempted to respond to fake XMLHttpRequest with " +
                               body + ", which is not a string or ArrayBuffer.");
            error.name = "InvalidBodyException";
        }
    }
    else if (!isString) {
        error = new Error("Attempted to respond to fake XMLHttpRequest with " +
                           body + ", which is not a string.");
        error.name = "InvalidBodyException";
    }

    if (error) {
        throw error;
    }
}

function convertToArrayBuffer(body, encoding) {
    if (body instanceof ArrayBuffer) {
        return body;
    }

    return new TextEncoder(encoding || "utf-8").encode(body).buffer;
}

function isXmlContentType(contentType) {
    return !contentType || /(text\/xml)|(application\/xml)|(\+xml)/.test(contentType);
}

function convertResponseBody(responseType, contentType, body) {
    if (responseType === "" || responseType === "text") {
        return body;
    } else if (supportsArrayBuffer && responseType === "arraybuffer") {
        return convertToArrayBuffer(body);
    } else if (responseType === "json") {
        try {
            return JSON.parse(body);
        } catch (e) {
            // Return parsing failure as null
            return null;
        }
    } else if (supportsBlob && responseType === "blob") {
        var blobOptions = {};
        if (contentType) {
            blobOptions.type = contentType;
        }
        return new Blob([convertToArrayBuffer(body)], blobOptions);
    } else if (responseType === "document") {
        if (isXmlContentType(contentType)) {
            return FakeXMLHttpRequest.parseXML(body);
        }
        return null;
    }
    throw new Error("Invalid responseType " + responseType);
}

function clearResponse(xhr) {
    if (xhr.responseType === "" || xhr.responseType === "text") {
        xhr.response = xhr.responseText = "";
    } else {
        xhr.response = xhr.responseText = null;
    }
    xhr.responseXML = null;
}

/**
 * Steps to follow when there is an error, according to:
 * https://xhr.spec.whatwg.org/#request-error-steps
 */
function requestErrorSteps(xhr) {
    clearResponse(xhr);
    xhr.errorFlag = true;
    xhr.requestHeaders = {};
    xhr.responseHeaders = {};

    if (xhr.readyState !== FakeXMLHttpRequest.UNSENT && xhr.sendFlag
        && xhr.readyState !== FakeXMLHttpRequest.DONE) {
        xhr.readyStateChange(FakeXMLHttpRequest.DONE);
        xhr.sendFlag = false;
    }
}

FakeXMLHttpRequest.parseXML = function parseXML(text) {
    // Treat empty string as parsing failure
    if (text !== "") {
        try {
            if (typeof DOMParser !== "undefined") {
                var parser = new DOMParser();
                var parsererrorNS = "";

                try {
                    var parsererrors = parser
                        .parseFromString("INVALID", "text/xml")
                        .getElementsByTagName("parsererror");
                    if (parsererrors.length) {
                        parsererrorNS = parsererrors[0].namespaceURI;
                    }
                } catch (e) {
                    // passing invalid XML makes IE11 throw
                    // so no namespace needs to be determined
                }

                var result;
                try {
                    result = parser.parseFromString(text, "text/xml");
                } catch (err) {
                    return null;
                }

                return result.getElementsByTagNameNS(parsererrorNS, "parsererror").length
                    ? null : result;
            }
            var xmlDoc = new window.ActiveXObject("Microsoft.XMLDOM");
            xmlDoc.async = "false";
            xmlDoc.loadXML(text);
            return xmlDoc.parseError.errorCode !== 0
                ? null : xmlDoc;
        } catch (e) {
            // Unable to parse XML - no biggie
        }
    }

    return null;
};

FakeXMLHttpRequest.statusCodes = {
    100: "Continue",
    101: "Switching Protocols",
    200: "OK",
    201: "Created",
    202: "Accepted",
    203: "Non-Authoritative Information",
    204: "No Content",
    205: "Reset Content",
    206: "Partial Content",
    207: "Multi-Status",
    300: "Multiple Choice",
    301: "Moved Permanently",
    302: "Found",
    303: "See Other",
    304: "Not Modified",
    305: "Use Proxy",
    307: "Temporary Redirect",
    400: "Bad Request",
    401: "Unauthorized",
    402: "Payment Required",
    403: "Forbidden",
    404: "Not Found",
    405: "Method Not Allowed",
    406: "Not Acceptable",
    407: "Proxy Authentication Required",
    408: "Request Timeout",
    409: "Conflict",
    410: "Gone",
    411: "Length Required",
    412: "Precondition Failed",
    413: "Request Entity Too Large",
    414: "Request-URI Too Long",
    415: "Unsupported Media Type",
    416: "Requested Range Not Satisfiable",
    417: "Expectation Failed",
    422: "Unprocessable Entity",
    500: "Internal Server Error",
    501: "Not Implemented",
    502: "Bad Gateway",
    503: "Service Unavailable",
    504: "Gateway Timeout",
    505: "HTTP Version Not Supported"
};

extend(FakeXMLHttpRequest.prototype, sinonEvent.EventTarget, {
    async: true,

    open: function open(method, url, async, username, password) {
        this.method = method;
        this.url = url;
        this.async = typeof async === "boolean" ? async : true;
        this.username = username;
        this.password = password;
        clearResponse(this);
        this.requestHeaders = {};
        this.sendFlag = false;

        if (FakeXMLHttpRequest.useFilters === true) {
            var xhrArgs = arguments;
            var defake = FakeXMLHttpRequest.filters.some(function (filter) {
                return filter.apply(this, xhrArgs);
            });
            if (defake) {
                FakeXMLHttpRequest.defake(this, arguments);
                return;
            }
        }
        this.readyStateChange(FakeXMLHttpRequest.OPENED);
    },

    readyStateChange: function readyStateChange(state) {
        this.readyState = state;

        var readyStateChangeEvent = new sinonEvent.Event("readystatechange", false, false, this);
        var event, progress;

        if (typeof this.onreadystatechange === "function") {
            try {
                this.onreadystatechange(readyStateChangeEvent);
            } catch (e) {
                this.logError("Fake XHR onreadystatechange handler", e);
            }
        }

        if (this.readyState === FakeXMLHttpRequest.DONE) {
            if (this.timedOut || this.aborted || this.status === 0) {
                progress = {loaded: 0, total: 0};
                event = (this.timedOut && "timeout") || (this.aborted && "abort") || "error";
            } else {
                progress = {loaded: 100, total: 100};
                event = "load";
            }

            if (supportsProgress) {
                this.upload.dispatchEvent(new sinonEvent.ProgressEvent("progress", progress, this));
                this.upload.dispatchEvent(new sinonEvent.ProgressEvent(event, progress, this));
                this.upload.dispatchEvent(new sinonEvent.ProgressEvent("loadend", progress, this));
            }

            this.dispatchEvent(new sinonEvent.ProgressEvent("progress", progress, this));
            this.dispatchEvent(new sinonEvent.ProgressEvent(event, progress, this));
            this.dispatchEvent(new sinonEvent.ProgressEvent("loadend", progress, this));
        }

        this.dispatchEvent(readyStateChangeEvent);
    },

    // Ref https://xhr.spec.whatwg.org/#the-setrequestheader()-method
    setRequestHeader: function setRequestHeader(header, value) {
        if (typeof value !== "string") {
            throw new TypeError("By RFC7230, section 3.2.4, header values should be strings. Got " + typeof value);
        }
        verifyState(this);

        var checkUnsafeHeaders = true;
        if (typeof this.unsafeHeadersEnabled === "function") {
            checkUnsafeHeaders = this.unsafeHeadersEnabled();
        }

        if (checkUnsafeHeaders && (getHeader(unsafeHeaders, header) !== null || /^(Sec-|Proxy-)/i.test(header))) {
            throw new Error("Refused to set unsafe header \"" + header + "\"");
        }

        value = normalizeHeaderValue(value);

        var existingHeader = getHeader(this.requestHeaders, header);
        if (existingHeader) {
            this.requestHeaders[existingHeader] += ", " + value;
        } else {
            this.requestHeaders[header] = value;
        }
    },

    setStatus: function setStatus(status) {
        var sanitizedStatus = typeof status === "number" ? status : 200;

        verifyRequestOpened(this);
        this.status = sanitizedStatus;
        this.statusText = FakeXMLHttpRequest.statusCodes[sanitizedStatus];
    },

    // Helps testing
    setResponseHeaders: function setResponseHeaders(headers) {
        verifyRequestOpened(this);

        var responseHeaders = this.responseHeaders = {};

        Object.keys(headers).forEach(function (header) {
            responseHeaders[header] = headers[header];
        });

        if (this.async) {
            this.readyStateChange(FakeXMLHttpRequest.HEADERS_RECEIVED);
        } else {
            this.readyState = FakeXMLHttpRequest.HEADERS_RECEIVED;
        }
    },

    // Currently treats ALL data as a DOMString (i.e. no Document)
    send: function send(data) {
        verifyState(this);

        if (!/^(head)$/i.test(this.method)) {
            var contentType = getHeader(this.requestHeaders, "Content-Type");
            if (this.requestHeaders[contentType]) {
                var value = this.requestHeaders[contentType].split(";");
                this.requestHeaders[contentType] = value[0] + ";charset=utf-8";
            } else if (supportsFormData && !(data instanceof FormData)) {
                this.requestHeaders["Content-Type"] = "text/plain;charset=utf-8";
            }

            this.requestBody = data;
        }

        this.errorFlag = false;
        this.sendFlag = this.async;
        clearResponse(this);
        this.readyStateChange(FakeXMLHttpRequest.OPENED);

        if (typeof this.onSend === "function") {
            this.onSend(this);
        }

        // Only listen if setInterval and Date are a stubbed.
        if (sinonXhr.supportsTimeout && typeof setInterval.clock === "object" && typeof Date.clock === "object") {
            var initiatedTime = Date.now();
            var self = this;

            // Listen to any possible tick by fake timers and check to see if timeout has
            // been exceeded. It's important to note that timeout can be changed while a request
            // is in flight, so we must check anytime the end user forces a clock tick to make
            // sure timeout hasn't changed.
            // https://xhr.spec.whatwg.org/#dfnReturnLink-2
            var clearIntervalId = setInterval(function () {
                // Check if the readyState has been reset or is done. If this is the case, there
                // should be no timeout. This will also prevent aborted requests and
                // fakeServerWithClock from triggering unnecessary responses.
                if (self.readyState === FakeXMLHttpRequest.UNSENT
                  || self.readyState === FakeXMLHttpRequest.DONE) {
                    clearInterval(clearIntervalId);
                } else if (typeof self.timeout === "number" && self.timeout > 0) {
                    if (Date.now() >= (initiatedTime + self.timeout)) {
                        self.triggerTimeout();
                        clearInterval(clearIntervalId);
                    }
                }
            }, 1);
        }

        this.dispatchEvent(new sinonEvent.Event("loadstart", false, false, this));
    },

    abort: function abort() {
        this.aborted = true;
        requestErrorSteps(this);
        this.readyState = FakeXMLHttpRequest.UNSENT;
    },

    error: function () {
        clearResponse(this);
        this.errorFlag = true;
        this.requestHeaders = {};
        this.responseHeaders = {};

        this.readyStateChange(FakeXMLHttpRequest.DONE);
    },

    triggerTimeout: function triggerTimeout() {
        if (sinonXhr.supportsTimeout) {
            this.timedOut = true;
            requestErrorSteps(this);
        }
    },

    getResponseHeader: function getResponseHeader(header) {
        if (this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED) {
            return null;
        }

        if (/^Set-Cookie2?$/i.test(header)) {
            return null;
        }

        header = getHeader(this.responseHeaders, header);

        return this.responseHeaders[header] || null;
    },

    getAllResponseHeaders: function getAllResponseHeaders() {
        if (this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED) {
            return "";
        }

        var responseHeaders = this.responseHeaders;
        var headers = Object.keys(responseHeaders)
            .filter(excludeSetCookie2Header)
            .reduce(function (prev, header) {
                var value = responseHeaders[header];

                return prev + (header + ": " + value + "\r\n");
            }, "");

        return headers;
    },

    setResponseBody: function setResponseBody(body) {
        verifyRequestSent(this);
        verifyHeadersReceived(this);
        verifyResponseBodyType(body, this.responseType);
        var contentType = this.overriddenMimeType || this.getResponseHeader("Content-Type");

        var isTextResponse = this.responseType === "" || this.responseType === "text";
        clearResponse(this);
        if (this.async) {
            var chunkSize = this.chunkSize || 10;
            var index = 0;

            do {
                this.readyStateChange(FakeXMLHttpRequest.LOADING);

                if (isTextResponse) {
                    this.responseText = this.response += body.substring(index, index + chunkSize);
                }
                index += chunkSize;
            } while (index < body.length);
        }

        this.response = convertResponseBody(this.responseType, contentType, body);
        if (isTextResponse) {
            this.responseText = this.response;
        }

        if (this.responseType === "document") {
            this.responseXML = this.response;
        } else if (this.responseType === "" && isXmlContentType(contentType)) {
            this.responseXML = FakeXMLHttpRequest.parseXML(this.responseText);
        }
        this.readyStateChange(FakeXMLHttpRequest.DONE);
    },

    respond: function respond(status, headers, body) {
        this.setStatus(status);
        this.setResponseHeaders(headers || {});
        this.setResponseBody(body || "");
    },

    uploadProgress: function uploadProgress(progressEventRaw) {
        if (supportsProgress) {
            this.upload.dispatchEvent(new sinonEvent.ProgressEvent("progress", progressEventRaw, this.upload));
        }
    },

    downloadProgress: function downloadProgress(progressEventRaw) {
        if (supportsProgress) {
            this.dispatchEvent(new sinonEvent.ProgressEvent("progress", progressEventRaw, this));
        }
    },

    uploadError: function uploadError(error) {
        if (supportsCustomEvent) {
            this.upload.dispatchEvent(new sinonEvent.CustomEvent("error", {detail: error}));
        }
    },

    overrideMimeType: function overrideMimeType(type) {
        if (this.readyState >= FakeXMLHttpRequest.LOADING) {
            throw new Error("INVALID_STATE_ERR");
        }
        this.overriddenMimeType = type;
    }
});

var states = {
    UNSENT: 0,
    OPENED: 1,
    HEADERS_RECEIVED: 2,
    LOADING: 3,
    DONE: 4
};

extend(FakeXMLHttpRequest, states);
extend(FakeXMLHttpRequest.prototype, states);

function useFakeXMLHttpRequest() {
    FakeXMLHttpRequest.restore = function restore(keepOnCreate) {
        if (sinonXhr.supportsXHR) {
            global.XMLHttpRequest = sinonXhr.GlobalXMLHttpRequest;
        }

        if (sinonXhr.supportsActiveX) {
            global.ActiveXObject = sinonXhr.GlobalActiveXObject;
        }

        delete FakeXMLHttpRequest.restore;

        if (keepOnCreate !== true) {
            delete FakeXMLHttpRequest.onCreate;
        }
    };
    if (sinonXhr.supportsXHR) {
        global.XMLHttpRequest = FakeXMLHttpRequest;
    }

    if (sinonXhr.supportsActiveX) {
        global.ActiveXObject = function ActiveXObject(objId) {
            if (objId === "Microsoft.XMLHTTP" || /^Msxml2\.XMLHTTP/i.test(objId)) {

                return new FakeXMLHttpRequest();
            }

            return new sinonXhr.GlobalActiveXObject(objId);
        };
    }

    return FakeXMLHttpRequest;
}

module.exports = {
    xhr: sinonXhr,
    FakeXMLHttpRequest: FakeXMLHttpRequest,
    useFakeXMLHttpRequest: useFakeXMLHttpRequest
};
