"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MongoBinaryDownload = void 0;
const tslib_1 = require("tslib");
const os_1 = tslib_1.__importDefault(require("os"));
const url_1 = require("url");
const path_1 = tslib_1.__importDefault(require("path"));
const fs_1 = require("fs");
const follow_redirects_1 = require("follow-redirects");
const zlib_1 = require("zlib");
const tar_stream_1 = tslib_1.__importDefault(require("tar-stream"));
const yauzl_1 = tslib_1.__importDefault(require("yauzl"));
const MongoBinaryDownloadUrl_1 = tslib_1.__importDefault(require("./MongoBinaryDownloadUrl"));
const https_proxy_agent_1 = require("https-proxy-agent");
const resolveConfig_1 = tslib_1.__importStar(require("./resolveConfig"));
const debug_1 = tslib_1.__importDefault(require("debug"));
const utils_1 = require("./utils");
const DryMongoBinary_1 = require("./DryMongoBinary");
const readline_1 = require("readline");
const errors_1 = require("./errors");
const log = (0, debug_1.default)('MongoMS:MongoBinaryDownload');
const retryableStatusCodes = [416, 429, 500, 503];
const retryableErrorCodes = [
    'ECONNRESET',
    'ETIMEDOUT',
    'ENOTFOUND',
    'ECONNREFUSED',
    'EPIPE',
    'EHOSTUNREACH',
    'EAI_AGAIN',
    'ENETUNREACH',
    'ECONNABORTED',
    'aborted',
];
/**
 * Download and extract the "mongod" binary
 */
class MongoBinaryDownload {
    dlProgress;
    _downloadingUrl;
    /** These options are kind of raw, they are not run through DryMongoBinary.generateOptions */
    binaryOpts;
    /** Determines if direct stdio should be done, or repeated `console.log`s */
    isTTY = false;
    constructor(opts) {
        (0, utils_1.assertion)(typeof opts.downloadDir === 'string', new Error('An DownloadDir must be specified!'));
        const version = opts.version ?? (0, resolveConfig_1.default)(resolveConfig_1.ResolveConfigVariables.VERSION);
        (0, utils_1.assertion)(typeof version === 'string', new Error('An MongoDB Binary version must be specified!'));
        // DryMongoBinary.generateOptions cannot be used here, because its async
        this.binaryOpts = {
            platform: opts.platform ?? os_1.default.platform(),
            arch: opts.arch ?? os_1.default.arch(),
            version: version,
            downloadDir: opts.downloadDir,
            checkMD5: opts.checkMD5 ?? (0, resolveConfig_1.envToBool)((0, resolveConfig_1.default)(resolveConfig_1.ResolveConfigVariables.MD5_CHECK)),
            systemBinary: opts.systemBinary ?? '',
            os: opts.os ?? { os: 'unknown' },
        };
        this.dlProgress = {
            current: 0,
            length: 0,
            totalMb: 0,
            lastPrintedAt: 0,
        };
        this.isTTY = process.stdout.isTTY;
    }
    /**
     * Get the full path with filename
     * @returns Absoulte Path with FileName
     */
    async getPath() {
        const opts = await DryMongoBinary_1.DryMongoBinary.generateOptions(this.binaryOpts);
        return DryMongoBinary_1.DryMongoBinary.combineBinaryName(this.binaryOpts.downloadDir, await DryMongoBinary_1.DryMongoBinary.getBinaryName(opts));
    }
    /**
     * Get the path of the already downloaded "mongod" file
     * otherwise download it and then return the path
     */
    async getMongodPath() {
        log('getMongodPath');
        const mongodPath = await this.getPath();
        if (await (0, utils_1.pathExists)(mongodPath)) {
            log(`getMongodPath: mongod path "${mongodPath}" already exists, using this`);
            return mongodPath;
        }
        const mongoDBArchive = await this.startDownload();
        await this.extract(mongoDBArchive);
        await fs_1.promises.unlink(mongoDBArchive);
        if (await (0, utils_1.pathExists)(mongodPath)) {
            return mongodPath;
        }
        throw new Error(`Cannot find downloaded mongod binary by path "${mongodPath}"`);
    }
    /**
     * Download the MongoDB Archive and check it against an MD5
     * @returns The MongoDB Archive location
     */
    async startDownload() {
        log('startDownload');
        const mbdUrl = new MongoBinaryDownloadUrl_1.default(this.binaryOpts);
        await (0, utils_1.mkdir)(this.binaryOpts.downloadDir);
        try {
            await fs_1.promises.access(this.binaryOpts.downloadDir, fs_1.constants.X_OK | fs_1.constants.W_OK); // check that this process has permissions to create files & modify file contents & read file contents
        }
        catch (err) {
            console.error(`Download Directory at "${this.binaryOpts.downloadDir}" does not have sufficient permissions to be used by this process\n` +
                'Needed Permissions: Write & Execute (-wx)\n');
            throw err;
        }
        const downloadUrl = await mbdUrl.getDownloadUrl();
        const mongoDBArchive = await this.download(downloadUrl);
        await this.makeMD5check(`${downloadUrl}.md5`, mongoDBArchive);
        return mongoDBArchive;
    }
    /**
     * Download MD5 file and check it against the MongoDB Archive
     * @param urlForReferenceMD5 URL to download the MD5
     * @param mongoDBArchive The MongoDB Archive file location
     *
     * @returns {undefined} if "checkMD5" is falsey
     * @returns {true} if the md5 check was successful
     * @throws if the md5 check failed
     */
    async makeMD5check(urlForReferenceMD5, mongoDBArchive) {
        log('makeMD5check: Checking MD5 of downloaded binary...');
        if (!this.binaryOpts.checkMD5) {
            log('makeMD5check: checkMD5 is disabled');
            return undefined;
        }
        const archiveMD5Path = await this.download(urlForReferenceMD5);
        const signatureContent = (await fs_1.promises.readFile(archiveMD5Path)).toString('utf-8');
        const regexMatch = signatureContent.match(/^\s*([\w\d]+)\s*/i);
        const md5SigRemote = regexMatch ? regexMatch[1] : null;
        const md5SigLocal = await (0, utils_1.md5FromFile)(mongoDBArchive);
        log(`makeMD5check: Local MD5: ${md5SigLocal}, Remote MD5: ${md5SigRemote}`);
        if (md5SigRemote !== md5SigLocal) {
            throw new errors_1.Md5CheckFailedError(md5SigLocal, md5SigRemote || 'unknown');
        }
        await fs_1.promises.unlink(archiveMD5Path);
        return true;
    }
    /**
     * Download file from downloadUrl
     * @param downloadUrl URL to download a File
     * @returns The Path to the downloaded archive file
     */
    async download(downloadUrl) {
        log('download');
        const proxy = process.env['yarn_https-proxy'] ||
            process.env.yarn_proxy ||
            process.env['npm_config_https-proxy'] ||
            process.env.npm_config_proxy ||
            process.env.https_proxy ||
            process.env.http_proxy ||
            process.env.HTTPS_PROXY ||
            process.env.HTTP_PROXY;
        const strictSsl = process.env.npm_config_strict_ssl === 'true';
        const urlObject = new url_1.URL(downloadUrl);
        urlObject.port = urlObject.port || '443';
        const requestOptions = {
            method: 'GET',
            rejectUnauthorized: strictSsl,
            agent: proxy ? new https_proxy_agent_1.HttpsProxyAgent(proxy) : undefined,
        };
        const filename = urlObject.pathname.split('/').pop();
        if (!filename) {
            throw new Error(`MongoBinaryDownload: missing filename for url "${downloadUrl}"`);
        }
        const downloadLocation = path_1.default.resolve(this.binaryOpts.downloadDir, filename);
        const tempDownloadLocation = path_1.default.resolve(this.binaryOpts.downloadDir, `${filename}.downloading`);
        log(`download: Downloading${proxy ? ` via proxy "${proxy}"` : ''}: "${downloadUrl}"`);
        if (await (0, utils_1.pathExists)(downloadLocation)) {
            log('download: Already downloaded archive found, skipping download');
            return downloadLocation;
        }
        this.assignDownloadingURL(urlObject);
        const downloadedFile = await this.httpDownload(urlObject, requestOptions, downloadLocation, tempDownloadLocation).catch((err) => {
            log(`Error:`, err);
            throw err;
        });
        return downloadedFile;
    }
    /**
     * Extract given Archive
     * @param mongoDBArchive Archive location
     * @returns extracted directory location
     */
    async extract(mongoDBArchive) {
        log('extract');
        const mongodbFullPath = await this.getPath();
        log(`extract: archive: "${mongoDBArchive}" final: "${mongodbFullPath}"`);
        await (0, utils_1.mkdir)(path_1.default.dirname(mongodbFullPath));
        const filter = (file) => /(?:bin\/(?:mongod(?:\.exe)?))$/i.test(file);
        if (/(.tar.gz|.tgz)$/.test(mongoDBArchive)) {
            await this.extractTarGz(mongoDBArchive, mongodbFullPath, filter);
        }
        else if (/.zip$/.test(mongoDBArchive)) {
            await this.extractZip(mongoDBArchive, mongodbFullPath, filter);
        }
        else {
            throw new Error(`MongoBinaryDownload: unsupported archive "${mongoDBArchive}" (downloaded from "${this._downloadingUrl ?? 'unknown'}"). Broken archive from MongoDB Provider?`);
        }
        if (!(await (0, utils_1.pathExists)(mongodbFullPath))) {
            throw new Error(`MongoBinaryDownload: missing mongod binary in "${mongoDBArchive}" (downloaded from "${this._downloadingUrl ?? 'unknown'}"). Broken archive from MongoDB Provider?`);
        }
        return mongodbFullPath;
    }
    /**
     * Extract a .tar.gz archive
     * @param mongoDBArchive Archive location
     * @param extractPath Directory to extract to
     * @param filter Method to determine which files to extract
     */
    async extractTarGz(mongoDBArchive, extractPath, filter) {
        log('extractTarGz');
        const extract = tar_stream_1.default.extract();
        extract.on('entry', (header, stream, next) => {
            if (filter(header.name)) {
                stream.pipe((0, fs_1.createWriteStream)(extractPath, {
                    mode: 0o775,
                }));
            }
            stream.on('end', () => next());
            stream.resume();
        });
        return new Promise((res, rej) => {
            (0, fs_1.createReadStream)(mongoDBArchive)
                .on('error', (err) => {
                rej(new errors_1.GenericMMSError('Unable to open tarball ' + mongoDBArchive + ': ' + err));
            })
                .pipe((0, zlib_1.createUnzip)())
                .on('error', (err) => {
                rej(new errors_1.GenericMMSError('Error during unzip for ' + mongoDBArchive + ': ' + err));
            })
                .pipe(extract)
                .on('error', (err) => {
                rej(new errors_1.GenericMMSError('Error during untar for ' + mongoDBArchive + ': ' + err));
            })
                .on('finish', res);
        });
    }
    /**
     * Extract a .zip archive
     * @param mongoDBArchive Archive location
     * @param extractPath Directory to extract to
     * @param filter Method to determine which files to extract
     */
    async extractZip(mongoDBArchive, extractPath, filter) {
        log('extractZip');
        return new Promise((resolve, reject) => {
            yauzl_1.default.open(mongoDBArchive, { lazyEntries: true }, (err, zipfile) => {
                if (err || !zipfile) {
                    return reject(err);
                }
                zipfile.readEntry();
                zipfile.on('end', () => resolve());
                zipfile.on('entry', (entry) => {
                    if (!filter(entry.fileName)) {
                        return zipfile.readEntry();
                    }
                    zipfile.openReadStream(entry, (err2, r) => {
                        if (err2 || !r) {
                            return reject(err2);
                        }
                        r.on('end', () => zipfile.readEntry());
                        r.pipe((0, fs_1.createWriteStream)(extractPath, {
                            mode: 0o775,
                        }));
                    });
                });
            });
        });
    }
    /**
     * Download given httpOptions to tempDownloadLocation, then move it to downloadLocation
     * @param url The URL to download the file from
     * @param httpOptions The httpOptions directly passed to https.get
     * @param downloadLocation The location the File should be after the download
     * @param tempDownloadLocation The location the File should be while downloading
     * @param maxRetries Maximum number of retries on download failure
     * @param baseDelay Base delay in milliseconds for retrying the download
     * @param timeout The time of inactivity(0bytes/second) after which the request is canceled
     */
    async httpDownload(url, httpOptions, downloadLocation, tempDownloadLocation, maxRetries, baseDelay = 1000, timeout = 60000) {
        log('httpDownload');
        const downloadUrl = this.assignDownloadingURL(url);
        const maxRedirects = parseInt((0, resolveConfig_1.default)(resolveConfig_1.ResolveConfigVariables.MAX_REDIRECTS) ?? '');
        const useHttpsOptions = {
            maxRedirects: Number.isNaN(maxRedirects) ? 2 : maxRedirects,
            ...httpOptions,
        };
        // Get maxRetries from config if not provided
        const retriesFromConfig = parseInt((0, resolveConfig_1.default)(resolveConfig_1.ResolveConfigVariables.MAX_RETRIES) ?? '');
        const retries = typeof maxRetries === 'number'
            ? maxRetries
            : !Number.isNaN(retriesFromConfig)
                ? retriesFromConfig
                : 3;
        // start at 1 attempt as there are basically no 0 attemps
        for (let attempt = 1; attempt <= retries; attempt++) {
            try {
                return await this.attemptDownload(url, useHttpsOptions, downloadLocation, tempDownloadLocation, downloadUrl, timeout);
            }
            catch (error) {
                const shouldRetry = (error instanceof errors_1.DownloadError &&
                    retryableStatusCodes.some((code) => error?.code === `Status: ${code}`)) ||
                    (error?.code && retryableErrorCodes.includes(error.code));
                if (!shouldRetry || attempt === retries) {
                    if (error instanceof errors_1.DownloadError) {
                        error.maxRetries = retries;
                        error.attempts = attempt;
                    }
                    throw error;
                }
                const base = baseDelay * Math.pow(2, attempt);
                const jitter = Math.floor(Math.random() * 1000);
                const delay = base + jitter;
                log(`httpDownload: attempt ${attempt + 1} failed with ${error.message}, retrying in ${delay}ms...`);
                await new Promise((resolve) => setTimeout(resolve, delay));
            }
        }
        const error = new errors_1.DownloadError(downloadUrl, 'Max retries exceeded', 'Max retries exceeded');
        error.maxRetries = retries;
        error.attempts = retries;
        throw error;
    }
    /**
     * Attempt to download the file from the given URL
     * This function is used internally by `httpDownload`
     * @param url The URL to download the file from
     * @param useHttpsOptions The httpOptions directly passed to https.get
     * @param downloadLocation The location the File should be after the download
     * @param tempDownloadLocation The location the File should be while downloading
     * @param downloadUrl The url as a string for error messages
     * @param timeout The time of inactivity(0bytes/second) after which the request is canceled
     * @private
     */
    async attemptDownload(url, useHttpsOptions, downloadLocation, tempDownloadLocation, downloadUrl, timeout = 60000) {
        /** Offset to resume from; for now a non-0 value indicates to use file "append" mode */
        let offset = 0;
        if ((0, resolveConfig_1.envToBool)((0, resolveConfig_1.default)(resolveConfig_1.ResolveConfigVariables.RESUME_DOWNLOAD))) {
            const stat = await (0, utils_1.statPath)(tempDownloadLocation);
            if (stat && stat.size != 0) {
                useHttpsOptions.headers = useHttpsOptions.headers ?? {};
                useHttpsOptions.headers['Range'] = `bytes=${stat.size}-`;
                offset = stat.size;
                log(`httpDownload: resuming download at ${offset}`);
            }
        }
        return new Promise((resolve, reject) => {
            log(`httpDownload: trying to download "${downloadUrl}"`);
            const protocol = (0, resolveConfig_1.envToBool)((0, resolveConfig_1.default)(resolveConfig_1.ResolveConfigVariables.USE_HTTP)) ? follow_redirects_1.http : follow_redirects_1.https;
            const request = protocol.get(url, useHttpsOptions, async (response) => {
                if (response.statusCode != 200 && response.statusCode != 206) {
                    if (response.statusCode === 403) {
                        reject(new errors_1.DownloadError(downloadUrl, "Status Code is 403 (MongoDB's 404)\n" +
                            "This means that the requested version-platform combination doesn't exist\n" +
                            "Try to use different version 'new MongoMemoryServer({ binary: { version: 'X.Y.Z' } })'\n" +
                            'List of available versions can be found here: ' +
                            'https://www.mongodb.com/download-center/community/releases/archive', 'Status: 403'));
                        return;
                    }
                    // delete temporary file on "416: Range Not Satisfiable" and let it re-try
                    if (response.statusCode === 416) {
                        await fs_1.promises.rm(tempDownloadLocation, { force: true });
                    }
                    reject(new errors_1.DownloadError(downloadUrl, `Status Code isn't 200! (it is ${response.statusCode})`, `Status: ${response.statusCode}`));
                    return;
                }
                // content-length, otherwise 0
                // note that content-length will not be the full size when resuming a download via "Range"
                let contentLength;
                if (typeof response.headers['content-length'] != 'string') {
                    log('Response header "content-length" is empty!');
                    contentLength = 0;
                }
                else {
                    contentLength = parseInt(response.headers['content-length'], 10);
                    if (Number.isNaN(contentLength)) {
                        log('Response header "content-length" resolved to NaN!');
                        contentLength = 0;
                    }
                }
                // error if the content-length header is missing or is 0 if config option "DOWNLOAD_IGNORE_MISSING_HEADER" is not set to "true"
                if (!(0, resolveConfig_1.envToBool)((0, resolveConfig_1.default)(resolveConfig_1.ResolveConfigVariables.DOWNLOAD_IGNORE_MISSING_HEADER)) &&
                    contentLength <= 0) {
                    reject(new errors_1.DownloadError(downloadUrl, 'Response header "content-length" does not exist or resolved to NaN', 'Invalid Content-Length'));
                    return;
                }
                this.dlProgress.current = 0;
                this.dlProgress.length = contentLength;
                this.dlProgress.totalMb = Math.round((this.dlProgress.length / 1048576) * 10) / 10;
                // "w" opens for write, creates the file if it does not exist, or truncates if it does
                // "a" opens for append, creates the file if it does not exist
                const flags = offset === 0 ? 'w' : 'a';
                // not using option "start" as we already open in "append" mode
                const fileStream = (0, fs_1.createWriteStream)(tempDownloadLocation, { /* start: offset, */ flags });
                fileStream.on('error', (err) => {
                    response.destroy();
                    // use the code if available, otherwise use the entire message
                    const code = err?.code ?? err.message;
                    reject(new errors_1.DownloadError(downloadUrl, err.message, code));
                });
                response.pipe(fileStream);
                fileStream.on('finish', async () => {
                    if (this.dlProgress.current < this.dlProgress.length &&
                        !useHttpsOptions.path?.endsWith('.md5')) {
                        reject(new errors_1.DownloadError(downloadUrl, `Too small (${this.dlProgress.current} bytes) mongod binary downloaded.`, 'File too small'));
                        return;
                    }
                    this.printDownloadProgress({ length: 0 }, true);
                    fileStream.close();
                    await fs_1.promises.rename(tempDownloadLocation, downloadLocation);
                    log(`httpDownload: moved "${tempDownloadLocation}" to "${downloadLocation}"`);
                    resolve(downloadLocation);
                });
                response.on('data', (chunk) => {
                    this.printDownloadProgress(chunk);
                });
                response.on('error', (err) => {
                    // use the code if available, otherwise use the entire message
                    const code = err?.code ?? err.message;
                    reject(new errors_1.DownloadError(downloadUrl, err.message, code));
                });
            });
            request.on('error', (err) => {
                console.error(`Could NOT download "${downloadUrl}"!`, err.message);
                // use the code if available, otherwise use the entire message
                const code = err?.code ?? err.message;
                reject(new errors_1.DownloadError(downloadUrl, err.message, code));
            });
            request.setTimeout(timeout, () => {
                request.destroy();
                reject(new errors_1.DownloadError(downloadUrl, `Request timeout after ${timeout}ms`, 'ETIMEDOUT'));
            });
        });
    }
    /**
     * Print the Download Progress to STDOUT
     * @param chunk A chunk to get the length
     */
    printDownloadProgress(chunk, forcePrint = false) {
        this.dlProgress.current += chunk.length;
        const now = Date.now();
        if (now - this.dlProgress.lastPrintedAt < 2000 && !forcePrint) {
            return;
        }
        this.dlProgress.lastPrintedAt = now;
        const percentComplete = Math.round(((100.0 * this.dlProgress.current) / this.dlProgress.length) * 10) / 10;
        const mbComplete = Math.round((this.dlProgress.current / 1048576) * 10) / 10;
        const crReturn = this.binaryOpts.platform === 'win32' ? '\x1b[0G' : '\r';
        const message = `Downloading MongoDB "${this.binaryOpts.version}": ${percentComplete}% (${mbComplete}mb / ${this.dlProgress.totalMb}mb)${crReturn}`;
        if (this.isTTY) {
            // if TTY overwrite last line over and over until finished and clear line to avoid residual characters
            (0, readline_1.clearLine)(process.stdout, 0); // this is because "process.stdout.clearLine" does not exist anymore
            process.stdout.write(message);
        }
        else {
            console.log(message);
        }
    }
    /**
     * Helper function to de-duplicate assigning "_downloadingUrl"
     */
    assignDownloadingURL(url) {
        this._downloadingUrl = url.href;
        return this._downloadingUrl;
    }
}
exports.MongoBinaryDownload = MongoBinaryDownload;
exports.default = MongoBinaryDownload;
//# sourceMappingURL=MongoBinaryDownload.js.map