// This module starts the HTTP server, parses the request and calls the requested endpoint

const jsl = require("svjsl");
const http = require("http");
const Readable = require("stream").Readable;
const fs = require("fs-extra");
const zlib = require("zlib");
const semver = require("semver");

const settings = require("../settings");
const debug = require("./verboseLogging");
const resolveIP = require("./resolveIP");
const logger = require("./logger");
const logRequest = require("./logRequest");
const convertFileFormat = require("./fileFormatConverter");
const parseURL = require("./parseURL");
const lists = require("./lists");
const analytics = require("./analytics");
const jokeSubmission = require("./jokeSubmission");
const auth = require("./auth");
const meter = require("./meter");
const languages = require("./languages");
const { RateLimiterMemory, RateLimiterRes } = require("rate-limiter-flexible");
const tr = require("./translate");

jsl.unused(RateLimiterRes); // typedef only


const init = () => {
    debug("HTTP", "Starting HTTP server...");
    return new Promise((resolve, reject) => {
        let endpoints = [];
        /** Whether or not the HTTP server could be initialized */
        let httpServerInitialized = false;

        /**
         * Initializes the HTTP server - should only be called once
         */
        const initHttpServer = () => {
            //#SECTION set up rate limiters
            let rl = new RateLimiterMemory({
                points: settings.httpServer.rateLimiting,
                duration: settings.httpServer.timeFrame
            });

            let rlSubm = new RateLimiterMemory({
                points: settings.jokes.submissions.rateLimiting,
                duration: settings.jokes.submissions.timeFrame
            });

            setTimeout(() => {
                if(!httpServerInitialized)
                    return reject(`HTTP server initialization timed out after ${settings.httpServer.startupTimeout} seconds.\nMaybe the port ${settings.httpServer.port} is already occupied or the firewall blocks the connection.\nTry killing the process that's blocking the port or change it in settings.httpServer.port`);
            }, settings.httpServer.startupTimeout * 1000)

            //#SECTION create HTTP server
            let httpServer = http.createServer(async (req, res) => {
                let parsedURL = parseURL(req.url);
                let ip = resolveIP(req);
                let localhostIP = resolveIP.isLocal(ip);
                let headerAuth = auth.authByHeader(req, res);
                let analyticsObject = {
                    ipAddress: ip,
                    urlPath: parsedURL.pathArray,
                    urlParameters: parsedURL.queryParams
                };
                let lang = parsedURL.queryParams ? parsedURL.queryParams.lang : "invalid-lang-code";

                if(languages.isValidLang(lang) !== true)
                    lang = settings.languages.defaultLanguage;

                debug("HTTP", `Incoming ${req.method} request from "${lang}-${ip.substring(0, 8)}${localhostIP ? `..." ${jsl.colors.fg.blue}(local)${jsl.colors.rst}` : "...\""} to ${req.url}`);
                
                let fileFormat = settings.jokes.defaultFileFormat.fileFormat;
                if(!jsl.isEmpty(parsedURL.queryParams) && !jsl.isEmpty(parsedURL.queryParams.format))
                    fileFormat = parseURL.getFileFormatFromQString(parsedURL.queryParams);

                if(req.url.length > settings.httpServer.maxUrlLength)
                    return respondWithError(res, 108, 414, fileFormat, tr(lang, "uriTooLong", req.url.length, settings.httpServer.maxUrlLength), lang, req.url.length);

                //#SECTION check lists
                try
                {
                    if(lists.isBlacklisted(ip))
                    {
                        logRequest("blacklisted", null, analyticsObject);
                        return respondWithError(res, 103, 403, fileFormat, tr(lang, "ipBlacklisted", settings.info.author.website), lang);
                    }

                    debug("HTTP", `Requested URL: ${parsedURL.initialURL}`);

                    if(settings.httpServer.allowCORS)
                    {
                        try
                        {
                            res.setHeader("Access-Control-Allow-Origin", "*");
                            res.setHeader("Access-Control-Request-Method", "GET");
                            res.setHeader("Access-Control-Allow-Methods", "GET, POST, HEAD, OPTIONS, PUT");
                            res.setHeader("Access-Control-Allow-Headers", "*");
                        }
                        catch(err)
                        {
                            console.log(`${jsl.colors.fg.red}Error while setting up CORS headers: ${err}${jsl.colors.rst}`);
                        }
                    }

                    res.setHeader("Allow", "GET, POST, HEAD, OPTIONS, PUT");

                    if(settings.httpServer.infoHeaders)
                        res.setHeader("API-Info", `${settings.info.name} v${settings.info.version} (${settings.info.docsURL})`);
                }
                catch(err)
                {
                    if(jsl.isEmpty(fileFormat))
                    {
                        fileFormat = settings.jokes.defaultFileFormat.fileFormat;
                        if(!jsl.isEmpty(parsedURL.queryParams) && !jsl.isEmpty(parsedURL.queryParams.format))
                            fileFormat = parseURL.getFileFormatFromQString(parsedURL.queryParams);
                    }

                    analytics({
                        type: "Error",
                        data: {
                            errorMessage: `Error while setting up the HTTP response to "${ip.substr(8)}...": ${err}`,
                            ipAddress: ip,
                            urlParameters: parsedURL.queryParams,
                            urlPath: parsedURL.pathArray
                        }
                    });
                    return respondWithError(res, 500, 100, fileFormat, tr(lang, "errSetupHttpResponse", err), lang);
                }

                meter.update("reqtotal", 1);
                meter.update("req1min", 1);
                meter.update("req10min", 1);

                //#SECTION GET
                if(req.method === "GET")
                {
                    //#MARKER GET
                    if(parsedURL.error === null)
                    {
                        let foundEndpoint = false;

                        let urlPath = parsedURL.pathArray;
                        let requestedEndpoint = "";
                        let lowerCaseEndpoints = [];
                        endpoints.forEach(ep => lowerCaseEndpoints.push(ep.name.toLowerCase()));

                        if(!jsl.isArrayEmpty(urlPath))
                            requestedEndpoint = urlPath[0];
                        else
                        {
                            try
                            {
                                rl.get(ip).then(rlRes => {
                                    if(rlRes)
                                        setRateLimitedHeaders(res, rlRes);

                                    foundEndpoint = true;

                                    if((rlRes && rlRes._remainingPoints < 0) && !lists.isWhitelisted(ip) && !headerAuth.isAuthorized)
                                    {
                                        analytics.rateLimited(ip);
                                        logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
                                        return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
                                    }
                                    else
                                        return serveDocumentation(req, res);
                                }).catch(rlRes => {
                                    if(typeof rlRes.message == "string")
                                        console.error(`Error while adding point to rate limiter: ${rlRes}`);
                                    else if(rlRes.remainingPoints <= 0)
                                    {
                                        logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
                                        return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
                                    }
                                });
                            }
                            catch(err)
                            {
                                // setRateLimitedHeaders(res, rlRes);
                                analytics.rateLimited(ip);
                                logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
                                return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
                            }
                        }

                        // Disable caching now that the request is not a docs request
                        if(settings.httpServer.disableCache)
                        {
                            res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate, no-transform");
                            res.setHeader("Pragma", "no-cache");
                            res.setHeader("Expires", "0");
                        }

                        // serve favicon:
                        if(!jsl.isEmpty(parsedURL.pathArray) && parsedURL.pathArray[0] == "favicon.ico")
                            return pipeFile(res, settings.documentation.faviconPath, "image/x-icon", 200);

                        endpoints.forEach(async (ep) => {
                            if(ep.name == requestedEndpoint)
                            {
                                let isAuthorized = headerAuth.isAuthorized;
                                let headerToken = headerAuth.token;

                                // now that the request is not a docs / favicon request, the blacklist is checked and the request is made eligible for rate limiting
                                if(!settings.endpoints.ratelimitBlacklist.includes(ep.name) && !isAuthorized)
                                {
                                    try
                                    {
                                        await rl.consume(ip, 1);
                                    }
                                    catch(err)
                                    {
                                        jsl.unused(err); // gets handled elsewhere
                                    }
                                }
                                
                                if(isAuthorized)
                                {
                                    debug("HTTP", `Requester has valid token ${jsl.colors.fg.green}${req.headers[settings.auth.tokenHeaderName] || null}${jsl.colors.rst}`);
                                    analytics({
                                        type: "AuthTokenIncluded",
                                        data: {
                                            ipAddress: ip,
                                            urlParameters: parsedURL.queryParams,
                                            urlPath: parsedURL.pathArray,
                                            submission: headerToken
                                        }
                                    });
                                }

                                foundEndpoint = true;

                                let callEndpoint = require(`.${ep.absPath}`);
                                let meta = callEndpoint.meta;
                                
                                if(!jsl.isEmpty(meta) && meta.skipRateLimitCheck === true)
                                {
                                    try
                                    {
                                        if(jsl.isEmpty(meta) || (!jsl.isEmpty(meta) && meta.noLog !== true))
                                        {
                                            if(!lists.isConsoleBlacklisted(ip))
                                                logRequest("success", null, analyticsObject);
                                        }
                                        return callEndpoint.call(req, res, parsedURL.pathArray, parsedURL.queryParams, fileFormat);
                                    }
                                    catch(err)
                                    {
                                        return respondWithError(res, 104, 500, fileFormat, tr(lang, "endpointInternalError", err), lang);
                                    }
                                }
                                else
                                {
                                    try
                                    {
                                        let rlRes = await rl.get(ip);

                                        if(rlRes)
                                            setRateLimitedHeaders(res, rlRes);

                                        if((rlRes && rlRes._remainingPoints < 0) && !lists.isWhitelisted(ip) && !isAuthorized)
                                        {
                                            logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
                                            analytics.rateLimited(ip);
                                            return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
                                        }
                                        else
                                        {
                                            if(jsl.isEmpty(meta) || (!jsl.isEmpty(meta) && meta.noLog !== true))
                                            {
                                                if(!lists.isConsoleBlacklisted(ip))
                                                    logRequest("success", null, analyticsObject);
                                            }
                                                
                                            return callEndpoint.call(req, res, parsedURL.pathArray, parsedURL.queryParams, fileFormat);
                                        }
                                    }
                                    catch(err)
                                    {
                                        // setRateLimitedHeaders(res, rlRes);
                                        logRequest("ratelimited", `IP: ${ip}`, analyticsObject);
                                        analytics.rateLimited(ip);
                                        return respondWithError(res, 100, 500, fileFormat, tr(lang, "generalInternalError", err), lang);
                                    }
                                }
                            }
                        });

                        setTimeout(() => {
                            if(!foundEndpoint)
                            {
                                if(!jsl.isEmpty(fileFormat) && req.url.toLowerCase().includes("format"))
                                    return respondWithError(res, 102, 404, fileFormat, tr(lang, "endpointNotFound", (!jsl.isEmpty(requestedEndpoint) ? requestedEndpoint : "/")), lang);
                                else
                                    return respondWithErrorPage(res, 404, tr(lang, "endpointNotFound", (!jsl.isEmpty(requestedEndpoint) ? requestedEndpoint : "/")));
                            }
                        }, 5000);
                    }
                }
                //#SECTION PUT / POST
                else if(req.method === "PUT" || req.method === "POST")
                {
                    //#MARKER Joke submission
                    let submissionsRateLimited = await rlSubm.get(ip);
                    let dryRun = (parsedURL.queryParams && parsedURL.queryParams["dry-run"] == true) || false;


                    if(!jsl.isEmpty(parsedURL.pathArray) && parsedURL.pathArray[0] == "submit" && !(submissionsRateLimited && submissionsRateLimited._remainingPoints <= 0 && !headerAuth.isAuthorized))
                    {
			if(!dryRun)
                            return respondWithError(res, 100, 500, fileFormat, "Joke submissions are disabled for the forseeable future.", lang);

                        let data = "";
                        let dataGotten = false;
                        req.on("data", chunk => {
                            data += chunk;

                            let payloadLength = byteLength(data);
                            if(payloadLength > settings.httpServer.maxPayloadSize)
                                return respondWithError(res, 107, 413, fileFormat, tr(lang, "payloadTooLarge", payloadLength, settings.httpServer.maxPayloadSize), lang);

                            if(!jsl.isEmpty(data))
                                dataGotten = true;

                            if(lists.isWhitelisted(ip))
                                return jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun);

                            if(!dryRun)
                            {
                                rlSubm.consume(ip, 1).then(() => {
                                    return jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun);
                                }).catch(rlRes => {
                                    if(rlRes.remainingPoints <= 0)
                                        return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
                                });
                            }
                            else
                            {
                                rl.consume(ip, 1).then(rlRes => {
                                    if(rlRes)
                                        setRateLimitedHeaders(res, rlRes);

                                    return jokeSubmission(res, data, fileFormat, ip, analyticsObject, dryRun);
                                }).catch(rlRes => {
                                    if(rlRes)
                                        setRateLimitedHeaders(res, rlRes);

                                    if(rlRes.remainingPoints <= 0)
                                        return respondWithError(res, 101, 429, fileFormat, tr(lang, "rateLimited", settings.httpServer.rateLimiting, settings.httpServer.timeFrame), lang);
                                });
                            }
                        });

                        setTimeout(() => {
                            if(!dataGotten)
                            {
                                debug("HTTP", "PUT request timed out");
                                rlSubm.consume(ip, 1);
                                return respondWithError(res, 105, 400, fileFormat, tr(lang, "requestEmptyOrTimedOut"), lang);
                            }
                        }, 3000);
                    }
                    else
                    {
                        //#MARKER Restart / invalid PUT / POST

                        if(submissionsRateLimited && submissionsRateLimited._remainingPoints <= 0 && !headerAuth.isAuthorized)
                            return respondWithError(res, 110, 429, fileFormat, tr(lang, "rateLimitedShort"), lang);

                        let data = "";
                        let dataGotten = false;
                        req.on("data", chunk => {
                            data += chunk;

                            if(!jsl.isEmpty(data))
                                dataGotten = true;

                            if(data == process.env.RESTART_TOKEN && parsedURL.pathArray != null && parsedURL.pathArray[0] == "restart")
                            {
                                res.writeHead(200, {"Content-Type": parseURL.getMimeTypeFromFileFormatString(fileFormat)});
                                res.end(convertFileFormat.auto(fileFormat, {
                                    "error": false,
                                    "message": `Restarting ${settings.info.name}`,
                                    "timestamp": new Date().getTime()
                                }, lang));
                                console.log(`\n\n[${logger.getTimestamp(" | ")}]  ${jsl.colors.fg.red}IP ${jsl.colors.fg.yellow}${ip.substr(0, 8)}[...]${jsl.colors.fg.red} sent a restart command\n\n\n${jsl.colors.rst}`);
                                process.exit(2); // if the process is exited with status 2, the package node-wrap will restart the process
                            }
                            else return respondWithErrorPage(res, 400, tr(lang, "invalidSubmissionOrWrongEndpoint", (parsedURL.pathArray != null ? parsedURL.pathArray[0] : "/")));
                        });

                        setTimeout(() => {
                            if(!dataGotten)
                            {
                                debug("HTTP", "PUT / POST request timed out");
                                return respondWithErrorPage(res, 400, tr(lang, "requestBodyIsInvalid"));
                            }
                        }, 3000);
                    }
                }
                //#SECTION HEAD / OPTIONS
                else if(req.method === "HEAD" || req.method === "OPTIONS")
                    serveDocumentation(req, res);
                //#SECTION invalid method
                else
                {
                    res.writeHead(405, {"Content-Type": parseURL.getMimeTypeFromFileFormatString(fileFormat)});
                    res.end(convertFileFormat.auto(fileFormat, {
                        "error": true,
                        "internalError": false,
                        "message": `Wrong method "${req.method}" used. Expected "GET", "OPTIONS" or "HEAD"`,
                        "timestamp": new Date().getTime()
                    }, lang));
                }
            });

            //#MARKER other HTTP stuff
            httpServer.on("error", err => {
                logger("error", `HTTP Server Error: ${err}`, true);
            });

            httpServer.listen(settings.httpServer.port, settings.httpServer.hostname, err => {
                if(!err)
                {
                    httpServerInitialized = true;
                    debug("HTTP", `${jsl.colors.fg.green}HTTP Server successfully listens on port ${settings.httpServer.port}${jsl.colors.rst}`);
                    return resolve();
                }
                else
                {
                    debug("HTTP", `${jsl.colors.fg.red}HTTP listener init encountered error: ${settings.httpServer.port}${jsl.colors.rst}`);
                    return reject(err);
                }
            });
        };

        fs.readdir(settings.endpoints.dirPath, (err1, files) => {
            if(err1)
                return reject(`Error while reading the endpoints directory: ${err1}`);
            files.forEach(file => {
                let fileName = file.split(".");
                fileName.pop();
                fileName = fileName.length > 1 ? fileName.join(".") : fileName[0];

                let endpointFilePath = `${settings.endpoints.dirPath}${file}`;

                if(fs.statSync(endpointFilePath).isFile())
                {
                    endpoints.push({
                        name: fileName,
                        desc: require(`.${endpointFilePath}`).meta.desc, // needs an extra . cause require() is relative to this file, whereas "fs" is relative to the project root
                        absPath: endpointFilePath
                    });
                }
            });

            //#MARKER call HTTP server init
            initHttpServer();
        });
    });
}


//#MARKER error stuff
/**
 * Sets necessary headers on a `res` object so the client knows their rate limiting numbers
 * @param {http.ServerResponse} res 
 * @param {RateLimiterRes} rlRes 
 */
function setRateLimitedHeaders(res, rlRes)
{
    try
    {
        let rlHeaders = {
            "Retry-After": rlRes.msBeforeNext ? Math.round(rlRes.msBeforeNext / 1000) : settings.httpServer.timeFrame,
            "RateLimit-Limit": settings.httpServer.rateLimiting,
            "RateLimit-Remaining": rlRes.msBeforeNext ? rlRes.remainingPoints : settings.httpServer.rateLimiting,
            "RateLimit-Reset": rlRes.msBeforeNext ? new Date(Date.now() + rlRes.msBeforeNext) : settings.httpServer.timeFrame
        }

        Object.keys(rlHeaders).forEach(key => {
            res.setHeader(key, rlHeaders[key]);
        });
    }
    catch(err)
    {
        let content = `Err: ${err}\nrlRes:\n${typeof rlRes == "object" ? JSON.stringify(rlRes, null, 4) : rlRes}\n\n\n`
        fs.appendFileSync("./msBeforeNext.log", content);
    }
}

/**
 * Ends the request with an error. This error gets pulled from the error registry
 * @param {http.ServerResponse} res 
 * @param {Number} errorCode The error code
 * @param {Number} responseCode The HTTP response code to end the request with
 * @param {String} fileFormat The file format to respond with - automatically gets converted to MIME type
 * @param {String} errorMessage Additional error info
 * @param {String} lang Language code of the request
 * @param {...any} args Arguments to replace numbered %-placeholders with. Only use objects that are strings or convertable to them with `.toString()`!
 */
const respondWithError = (res, errorCode, responseCode, fileFormat, errorMessage, lang, ...args) => {
    try
    {
        errorCode = errorCode.toString();
        let errFromRegistry = require("../data/errorMessages")[errorCode];
        let errObj = {};

        if(errFromRegistry == undefined)
            throw new Error(`Couldn't find errorMessages module or Node is using an outdated, cached version`);

        if(!lang || languages.isValidLang(lang) !== true)
            lang = settings.languages.defaultLanguage;

        let insArgs = (texts, insertions) => {
            if(!Array.isArray(insertions) || insertions.length <= 0)
                return texts;

            insertions.forEach((ins, i) => {

                if(Array.isArray(texts))
                    texts = texts.map(tx => tx.replace(`%${i + 1}`, ins));
                else if(typeof texts == "string")
                    texts = texts.replace(`%${i + 1}`, ins);
            });

            return texts;
        };

        if(fileFormat != "xml")
        {
            errObj = {
                "error": true,
                "internalError": errFromRegistry.errorInternal,
                "code": errorCode,
                "message": insArgs(errFromRegistry.errorMessage[lang], args) || insArgs(errFromRegistry.errorMessage[settings.languages.defaultLanguage], args),
                "causedBy": insArgs(errFromRegistry.causedBy[lang], args) || insArgs(errFromRegistry.causedBy[settings.languages.defaultLanguage], args),
                "timestamp": new Date().getTime()
            }
        }
        else if(fileFormat == "xml")
        {
            errObj = {
                "error": true,
                "internalError": errFromRegistry.errorInternal,
                "code": errorCode,
                "message": insArgs(errFromRegistry.errorMessage[lang], args) || insArgs(errFromRegistry.errorMessage[settings.languages.defaultLanguage], args),
                "causedBy": {"cause": insArgs(errFromRegistry.causedBy[lang], args) || insArgs(errFromRegistry.causedBy[settings.languages.defaultLanguage], args)},
                "timestamp": new Date().getTime()
            }
        }

        if(!jsl.isEmpty(errorMessage))
            errObj.additionalInfo = errorMessage;

        let converted = convertFileFormat.auto(fileFormat, errObj, lang).toString();

        return pipeString(res, converted, parseURL.getMimeTypeFromFileFormatString(fileFormat), typeof responseCode === "number" ? responseCode : 500);
    }
    catch(err)
    {
        let errMsg = `Internal error while sending error message.\nOh, the irony...\n\nPlease contact me (${settings.info.author.website}) and provide this additional info:\n${err}`;
        return pipeString(res, errMsg, "text/plain", responseCode);
    }
};

/**
 * Responds with an error page (which one is based on the status code).
 * Defaults to 500
 * @param {http.ServerResponse} res 
 * @param {(404|500)} [statusCode=500] HTTP status code - defaults to 500
 * @param {String} [error] Additional error message that gets added to the "API-Error" response header
 */
const respondWithErrorPage = (res, statusCode, error) => {

    statusCode = parseInt(statusCode);

    if(isNaN(statusCode))
    {
        statusCode = 500;
        error += ((!jsl.isEmpty(error) ? " - Ironically, an additional " : "An ") + "error was encountered while sending this error page: \"statusCode is not a number (in: httpServer.respondWithErrorPage)\"");
    }

    if(!jsl.isEmpty(error))
    {
        res.setHeader("Set-Cookie", `errorInfo=${JSON.stringify({"API-Error-Message": error, "API-Error-StatusCode": statusCode})}`);
        res.setHeader("API-Error", error);
    }

    return pipeFile(res, settings.documentation.errorPagePath, "text/html", statusCode);
}

//#MARKER response piping
/**
 * Pipes a string into a HTTP response
 * @param {http.ServerResponse} res The HTTP res object
 * @param {String} text The response body
 * @param {String} mimeType The MIME type to respond with
 * @param {Number} [statusCode=200] The status code to respond with - defaults to 200
 */
const pipeString = (res, text, mimeType, statusCode = 200) => {
    try
    {
        statusCode = parseInt(statusCode);
        if(isNaN(statusCode))
            throw new Error("Invalid status code");
    }
    catch(err)
    {
        res.writeHead(500, {"Content-Type": `text/plain; charset=UTF-8`});
        res.end("INTERNAL_ERR:STATUS_CODE_NOT_INT");
        return;
    }

    let s = new Readable();
    s._read = () => {};
    s.push(text);
    s.push(null);

    if(!res.writableEnded)
    {
        s.pipe(res);

        if(!res.headersSent)
        {
            res.writeHead(statusCode, {
                "Content-Type": `${mimeType}; charset=UTF-8`,
                "Content-Length": byteLength(text) // Content-Length needs the byte length, not the char length
            });
        }
    }
}

/**
 * Pipes a file into a HTTP response
 * @param {http.ServerResponse} res The HTTP res object
 * @param {String} filePath Path to the file to respond with - relative to the project root directory
 * @param {String} mimeType The MIME type to respond with
 * @param {Number} [statusCode=200] The status code to respond with - defaults to 200
 */
const pipeFile = (res, filePath, mimeType, statusCode = 200) => {
    try
    {
        statusCode = parseInt(statusCode);
        if(isNaN(statusCode))
            throw new Error("err_statuscode_isnan");
    }
    catch(err)
    {
        return respondWithErrorPage(res, 500, `Encountered internal server error while piping file: wrong type for status code.`);
    }

    if(!fs.existsSync(filePath))
        return respondWithErrorPage(res, 404, `Internal error: file at "${filePath}" not found.`);

    try
    {
        if(!res.headersSent)
        {
            res.writeHead(statusCode, {
                "Content-Type": `${mimeType}; charset=UTF-8`,
                "Content-Length": fs.statSync(filePath).size
            });
        }

        let readStream = fs.createReadStream(filePath);
        readStream.pipe(res);
    }
    catch(err)
    {
        logger("fatal", err, true);
    }
}

//#MARKER serve docs
/**
 * Serves the documentation page
 * @param {http.IncomingMessage} req The HTTP req object
 * @param {http.ServerResponse} res The HTTP res object
 */
const serveDocumentation = (req, res) => {
    let resolvedURL = parseURL(req.url);

    if(!lists.isConsoleBlacklisted(resolveIP(req)))
    {
        logRequest("docs", null, {
            ipAddress: resolveIP(req),
            urlParameters: resolvedURL.queryParams,
            urlPath: resolvedURL.pathArray
        });
    }

    let selectedEncoding = getAcceptedEncoding(req);
    let fileExtension = "";


    if(selectedEncoding != null)
        fileExtension = `.${getFileExtensionFromEncoding(selectedEncoding)}`;

    debug("HTTP", `Serving docs with encoding "${selectedEncoding}"`);

    let filePath = `${settings.documentation.compiledPath}documentation.html${fileExtension}`;
    let fallbackPath = `${settings.documentation.compiledPath}documentation.html`;

    fs.exists(filePath, exists => {
        if(exists)
        {
            if(selectedEncoding == null)
                selectedEncoding = "identity"; // identity = no encoding (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding)
            
            res.setHeader("Content-Encoding", selectedEncoding);

            return pipeFile(res, filePath, "text/html", 200);
        }
        else
            return pipeFile(res, fallbackPath, "text/html", 200);
    }); 
}

//#MARKER util
/**
 * Returns the name of the client's accepted encoding with the highest priority
 * @param {http.IncomingMessage} req The HTTP req object
 * @returns {null|"gzip"|"deflate"|"br"} Returns null if no encodings are supported, else returns the encoding name
 */
const getAcceptedEncoding = req => {
    let selectedEncoding = null;

    let encodingPriority = [];

    settings.httpServer.encodings.brotli  && encodingPriority.push("br");
    settings.httpServer.encodings.gzip    && encodingPriority.push("gzip");
    settings.httpServer.encodings.deflate && encodingPriority.push("deflate");

    encodingPriority = encodingPriority.reverse();

    let acceptedEncodings = [];
    if(req.headers["accept-encoding"])
        acceptedEncodings = req.headers["accept-encoding"].split(/\s*[,]\s*/gm);
    acceptedEncodings = acceptedEncodings.reverse();

    encodingPriority.forEach(encPrio => {
        if(acceptedEncodings.includes(encPrio))
            selectedEncoding = encPrio;
    });

    return selectedEncoding;
}

/**
 * Returns the length of a string in bytes
 * @param {String} str
 * @returns {Number}
 */
function byteLength(str)
{
    if(!str)
        return 0;
    return Buffer.byteLength(str, "utf8");
}

/**
 * Returns the file extension for the provided encoding (without dot prefix)
 * @param {null|"gzip"|"deflate"|"br"} encoding
 * @returns {String}
 */
const getFileExtensionFromEncoding = encoding => {
    switch(encoding)
    {
        case "gzip":
            return "gz";
        case "deflate":
            return "zz";
        case "br":
        case "brotli":
            return "br";
        default:
            return "";
    }
}

/**
 * Tries to serve data with an encoding supported by the client, else just serves the raw data
 * @param {http.IncomingMessage} req The HTTP req object
 * @param {http.ServerResponse} res The HTTP res object
 * @param {String} data The data to send to the client
 * @param {String} mimeType The MIME type to respond with
 */
function tryServeEncoded(req, res, data, mimeType)
{
    let selectedEncoding = getAcceptedEncoding(req);

    debug("HTTP", `Trying to serve with encoding ${selectedEncoding}`);

    if(selectedEncoding)
        res.setHeader("Content-Encoding", selectedEncoding);
    else
        res.setHeader("Content-Encoding", "identity");

    switch(selectedEncoding)
    {
        case "br":
            if(!semver.lt(process.version, "v11.7.0")) // Brotli was added in Node v11.7.0
            {
                zlib.brotliCompress(data, (err, encRes) => {
                    if(!err)
                        return pipeString(res, encRes, mimeType);
                    else
                        return pipeString(res, `Internal error while encoding text into ${selectedEncoding}: ${err}`, mimeType);
                });
            }
            else
            {
                res.setHeader("Content-Encoding", "identity");

                return pipeString(res, data, mimeType);
            }
        break;
        case "gzip":
            zlib.gzip(data, (err, encRes) => {
                if(!err)
                    return pipeString(res, encRes, mimeType);
                else
                    return pipeString(res, `Internal error while encoding text into ${selectedEncoding}: ${err}`, mimeType);
            });
        break;
        case "deflate":
            zlib.deflate(data, (err, encRes) => {
                if(!err)
                    return pipeString(res, encRes, mimeType);
                else
                    return pipeString(res, `Internal error while encoding text into ${selectedEncoding}: ${err}`, mimeType);
            });
        break;
        default:
            res.setHeader("Content-Encoding", "identity");

            return pipeString(res, data, mimeType);
    }
}

module.exports = { init, respondWithError, respondWithErrorPage, pipeString, pipeFile, serveDocumentation, getAcceptedEncoding, getFileExtensionFromEncoding, tryServeEncoded };
