/// HUGE-UPLOADER-NODEJS-CLIENT

const EventEmitter = require('events');
const fs = require("fs");
const fetch = require("perf360-microservice/node_modules/node-fetch");
const FormData = require("form-data");


class HugeUploaderNodeClient {

    constructor(params) {
        this.endpoint = params.endpoint;
        this.file = params.file;
        this.headers = params.headers || {};
        this.postParams = params.postParams;
        this.chunkSize = params.chunkSize || 10;
        this.chunkSizeBytes = this.chunkSize * 1024 * 1024;
        this.retries = params.retries || 5;
        this.delayBeforeRetry = params.delayBeforeRetry || 5;

        this.start = 0;
        this.chunk = Buffer.alloc(this.chunkSizeBytes);
        this.chunkCount = 0;
        this.retriesCount = 0;

        this._eventTarget = new EventEmitter();

        this._validateParams();

        const stats = require("fs").statSync(this.file);
        this.fileSize = stats.size;
        this.totalChunks = Math.ceil(this.fileSize / this.chunkSizeBytes);

        this.headers['uploader-file-id'] = this._uniqid();
        this.headers['uploader-chunks-total'] = this.totalChunks;

        this._startSending();
    }

    /**
     * Subscribe to an event
     */
     on(eType, fn) {
        this._eventTarget.on(eType, fn);
    }

    /**
     * Validate params and throw error if not of the right type
     */
    _validateParams() {
        if (!this.endpoint || !this.endpoint.length) throw new TypeError('endpoint must be defined');
        if (typeof this.file !== 'string') throw new TypeError('file must be a string');
        if (this.headers && typeof this.headers !== 'object') throw new TypeError('headers must be null or an object');
        if (this.postParams && typeof this.postParams !== 'object') throw new TypeError('postParams must be null or an object');
        if (this.chunkSize && (typeof this.chunkSize !== 'number' || this.chunkSize === 0)) throw new TypeError('chunkSize must be a positive number');
        if (this.retries && (typeof this.retries !== 'number' || this.retries === 0)) throw new TypeError('retries must be a positive number');
        if (this.delayBeforeRetry && (typeof this.delayBeforeRetry !== 'number')) throw new TypeError('delayBeforeRetry must be a positive number');
    }

    /**
     * Generate uniqid based on file size, date & pseudo random number generation
     */
    _uniqid() {
        return Math.floor(Math.random() * 100000000) + Date.now() + this.fileSize;
    }

    /**
     * Get portion of the file of x bytes corresponding to chunkSize
     */
    async _getChunk() {
        const nread = await new Promise((resolve,reject)=> {
            console.log("reading fd",this.fd,"for chunk",this.chunkCount);
            fs.read(this.fd,this.chunk,0,this.chunkSizeBytes,null,(err,nread)=>{
                if(err) return reject(err);
                return resolve(nread);
            })
        });

        if(nread===0) {
            console.log("closing fd",this.fd,"after chunk",this.chunkCount,"total chunk=",this.totalChunks);
            await new Promise((resolve,reject)=>fs.close(this.fd,err=>{
                if(err) return reject(err);
                return resolve();
            }));
            return;
        }

        console.log("read",nread,"bytes for",this.fd,"for chunk",this.chunkCount);

        if(nread<this.chunkSizeBytes)
            return { data: this.chunk.slice(0,nread), lastChunk: true };
        else
            return { data: this.chunk, lastChunk: false };
    }

    /**
     * Send chunk of the file with appropriate headers and add post parameters if it's last chunk
     */
    _sendChunk({ data, lastChunk }) {
        console.log("sending chunk",this.chunkCount,"lastChunk=",lastChunk);
        const form = new FormData();

        // send post fields on last request
        if (lastChunk) Object.keys(this.postParams).forEach(key => form.append(key, this.postParams[key]));

        form.append('file', data,{contentType:"application/octet-stream"});
        this.headers['uploader-chunk-number'] = this.chunkCount;

        return fetch(this.endpoint, { method: 'POST', headers: this.headers, body: form });
    }

    /**
     * Called on net failure. If retry counter !== 0, retry after delayBeforeRetry
     */
    _manageRetries() {
        if (this.retriesCount++ < this.retries) {
            setTimeout(() => this._sendChunks(), this.delayBeforeRetry * 1000);
            this._eventTarget.emit('fileRetry', { 
                message: `An error occured uploading chunk ${this.chunkCount}. ${this.retries - this.retriesCount} retries left`, 
                chunk: this.chunkCount, 
                retriesLeft: this.retries - this.retriesCount 
            });
            return;
        }

        this._eventTarget.emit('error', `An error occured uploading chunk ${this.chunkCount}. No more retries, stopping upload`);
    }

    /**
     * Manage the whole upload by calling getChunk & sendChunk
     * handle errors & retries and dispatch events
     */
    _sendChunks() {
        return this._getChunk()
        .then((out) => this._sendChunk(out))
        .then((res) => { 
            console.log("huge uploader res.status",res.status);
            if (res.status === 200 || res.status === 201 || res.status === 204) {
                if (++this.chunkCount < this.totalChunks) this._sendChunks();
                else this._eventTarget.emit('finish');

                const percentProgress = Math.round((100 / this.totalChunks) * this.chunkCount);
                this._eventTarget.emit('progress', percentProgress);
            }

            // errors that might be temporary, wait a bit then retry
            else if ([408, 502, 503, 504].includes(res.status)) {
                this._manageRetries();
            }

            else {
                this._eventTarget.emit('error', `Server responded with ${res.status}. Stopping upload`);
            }
        })
        .catch((err) => { 
            console.log("huge uploader err",err);

            // this type of error can happen after network disconnection on CORS setup
            this._manageRetries();
        });
    }

    /**
     * Start sending chunks
     */
    _startSending() {
        new Promise((resolve,reject)=>{
            fs.open(this.file,'r',(err,fd)=>{
                if(err) return reject(err);
                this.fd = fd;
                console.log("opened fd",fd);
                return resolve();
            });
        })
        .then(()=>this._sendChunks())
        .catch((err)=>{
            console.log("huge uploader start err",err);
            this._eventTarget.emit('error', 'Failed starting to sending chunks');
        });
    }

}

module.exports = HugeUploaderNodeClient;

/// HUGE-UPLOADER EXAMPLE

const HugeUploader = require('huge-uploader');

        const uploader = new HugeUploader({ 
            endpoint, 
            file: file.path,
            postParams: { name: file.name }
        });
        uploader.on('error', (err) => {
            console.error('Something bad happened', err);
            return reject(err);
        });
        uploader.on('progress', (progress) => {
            console.log(`The upload is at ${progress}%`);
        });
        uploader.on('finish', () => {
            console.log('Upload finished:',file.name);
            console.timeEnd(`Uploaded...${file.name}`);
            return resolve();
        });


/// HUGE-DOWNLOADER-NODEJS-CLIENT


const EventEmitter = require('events');
const fs = require("fs");
const fetch = require("perf360-microservice/lib/fetchTimeout");
const FormData = require("form-data");
const CombinedStream = require('combined-stream');

class HugeDownloader {

    constructor(params) {
        console.log("HugeDownloader params",params);
        this.endpoint = params.endpoint;
        this.file = params.file;
        this.target = params.target;
        this.chunkSize = params.chunkSize || 10;
        this.chunkSizeBytes = this.chunkSize * 1024 * 1024;
        this.chunkTimeout = params.chunkTimeout || (600*1000);
        this.chunkCount = 0;
        this.chunksSent = 0;

        this._validateParams();

        this._eventTarget = new EventEmitter();
        this._startDownloading();
    }

    /**
     * Subscribe to an event
     */
    on(eType, fn) {
        this._eventTarget.on(eType, fn);
    }

    /**
     * Validate params and throw error if not of the right type
     */
    _validateParams() {
        if (!this.endpoint || !this.endpoint.length) throw new TypeError('endpoint must be defined');
        if (typeof this.file !== 'string') throw new TypeError('file must be a string');
        if (typeof this.target !== 'string') throw new TypeError('target must be a string');
        if (this.chunkSize && (typeof this.chunkSize !== 'number' || this.chunkSize === 0)) throw new TypeError('chunkSize must be a positive number');
    }

    _getMeta() {
        return fetch(`${this.endpoint}?file=${this.file}&meta=true&chunkSize=${this.chunkSizeBytes}`)
        .then(res=>res.json())
        .then(meta=>{
            this.totalChunks = meta.totalChunks;
            this.totalSize = meta.stats.size;
        });
    }

    async _getChunk() {
        const currentChunk = this.chunkCount;
        this.stream.append(async next=>{
            console.log("Downloading chunk",currentChunk,"of",this.totalChunks-1,"...");

            const res = await fetch(`${this.endpoint}?file=${this.file}&chunkSize=${this.chunkSizeBytes}&chunk=${currentChunk}`, {
                timeout: this.chunkTimeout
            });
            console.log("Received res for chunk",currentChunk,"res.ok=",res.ok,"res.status=",res.status);

            if(!res.ok) {
                console.log("Error downloading chunk",currentChunk,res.ok,res.status,res.error);
                throw new Error(`Initial error downloading file - ${res.error}`);
            }

            await new Promise((resolve,reject)=>{
                
                let timer = setTimeout(() => {
                    this.stream.close();
                    console.log("Timed out piping chunk to stream",currentChunk,"of",this.totalChunks);
                    reject({reason: 'Timed out piping chunk to stream', meta: {}});
                }, this.chunkTimeout);

                console.time(`Piping res.body to combined stream for chunk ${currentChunk}`);
                next(res.body);

                res.body.on('end',()=>{
                    clearTimeout(timer)
                    this.chunksSent++;

                    const percentProgress = Math.round((100 / this.totalChunks) * this.chunksSent);
                    this._eventTarget.emit('progress', percentProgress);

                    console.timeEnd(`Piping res.body to combined stream for chunk ${currentChunk}`);
                    console.log("Downloaded chunk",currentChunk,"of",this.totalChunks-1,"!");
                    resolve();
                });
                
            });
        });
    }

    async _getChunks() {
        for(this.chunkCount=0; this.chunkCount<this.totalChunks; this.chunkCount++) {
            await this._getChunk();
        }

        const fileStream = fs.createWriteStream(this.target);
        let timer;

        await new Promise((resolve, reject) => {
            const errorHandler = (error) => {
                clearTimeout(timer);
                reject({reason: 'Unable to download file', meta: {error}})
            };

            console.log("piping combined stream to file");
            console.time("combined stream to file");
            this.stream
                .on("error", errorHandler)
                .pipe(fileStream);

            
            fileStream
                .on('open', () => {
                    timer = setTimeout(() => {
                        fileStream.close()
                        reject({reason: 'Timed out writing to file', meta: {}})
                    }, this.chunkTimeout*this.chunkCount)
                })
                .on('error', errorHandler)
                .on("finish", ()=>{
                    clearTimeout(timer);
                    console.timeEnd("combined stream to file");
                    resolve();
                });
        });

        this._eventTarget.emit('finish');
    }

    _startDownloading() {
        this._getMeta()
        .then(()=>{
            this.stream = CombinedStream.create({ maxDataSize: this.totalSize });
            return this._getChunks()
        })
        .catch(err=>{
            console.log("huge downloader Error",err);
            this._eventTarget.emit('error', 'Failed starting to download chunks');
        })
    }
}

module.exports = HugeDownloader;


/// HUGE-DOWNLOADER-EXPRESS-ROUTE


/**
 * Download File in Chunks
 * @route GET /api/sandbox/{id}/download
 * @param {string} id.path.required
 * @group Sandbox
 */
exports.download = async function (req, res) {
    try {
        if(!req.query.file || typeof req.query.file != "string") 
            throw new Error("file is required");

        const { sessionModelName, sessionId } = parseSandboxId(req.params.id);
        const { sandbox } = await getSandbox(sessionModelName,sessionId);

        const fullPath = path.join(sandbox,req.query.file);
        const chunkSize = req.query.chunkSize?parseInt(req.query.chunkSize):(10 * 1024 * 1024);

        console.log("[huge-downloader] Getting file stats for requested chunk download",fullPath);
        const stats = await fs.promises.stat(fullPath);
        const totalChunks = Math.ceil(stats.size / chunkSize);

        console.log("[huge-downloader] totalChunks=",totalChunks,"fileSize=",stats.size,"chunkSize=",chunkSize);
        if(req.query.meta) {    
            return res.apiResponse({ 
                chunkSize,
                totalChunks,
                stats
            });
        }
        
        if(req.query.chunk) {
            const chunkIndex = parseInt(req.query.chunk);
            console.log("[huge-downloader] requesting chunk",chunkIndex,"of",totalChunks-1,"for file",fullPath,"at size",chunkSize);

            if(isNaN(chunkIndex)) throw new Error("chunk is invalid");
            let chunk = Buffer.alloc(chunkSize);

            console.log("[huge-downloader] opening",fullPath);
            const fd = await new Promise((resolve,reject)=>{
                fs.open(fullPath,'r',(err,fd)=>{
                    if(err) return reject(err);
                    return resolve(fd);
                });
            });
            
            console.log("[huge-downloader] reading fd",fd,"for chunk",req.query.chunk,"of",fullPath);
            const nread = await new Promise((resolve,reject)=>{
                
                fs.read(fd,chunk,0,chunkSize,chunkIndex*chunkSize,(err,nread)=>{
                    if(err) return reject(err);
                    return resolve(nread);
                })
            });

            console.log("[huge-downloader] closing",fd,"for",fullPath);
            await new Promise((resolve,reject)=>
                fs.close(fd,err=>err?reject(err):resolve())
            );

            let data = chunk;
            if(nread<chunkSize)
                data = chunk.slice(0,nread);

            console.log("[huge-downloader] responding with",nread,"bytes for",fullPath);
            res.writeHead(200, [
                ['Content-Type', 'application/octet-stream'],
                ['X-Nread',nread]
            ]);
            res.end(Buffer.from(data, 'base64'));
            chunk = null; data = null;

            console.log("[huge-downloader] responded with",nread,"bytes for",fullPath);
            return;
        }
        
        return res.apiError("specify chunk or meta");
    }
    catch(err) {
        console.error("[huge-downloader] Error",err);
        return res.apiError("Error", err);
    }
};
