let tarball = {};

if (typeof module === "object" && typeof module.exports === "object") {
    // CommonJS
    module.exports = tarball;
} else if (typeof this === "object") {
    // Browser
    // use this instead of window, since window might not exist and throw and error
    this.tarball = tarball;
}

tarball.TarReader = class {
    constructor() {
        this.fileInfo = [];
    }

    readFile(file) {
        return new Promise((resolve, reject) => {
            let reader = new FileReader();
            reader.onload = (event) => {
                this.buffer = event.target.result;
                this.fileInfo = [];
                this._readFileInfo();
                resolve(this.fileInfo);
            };
            reader.readAsArrayBuffer(file);
        });
    }

    readArrayBuffer(arrayBuffer) {
        this.buffer = arrayBuffer;
        this.fileInfo = [];
        this._readFileInfo();
        return this.fileInfo;
    }

    _readFileInfo() {
        this.fileInfo = [];
        let offset = 0;
        let file_size = 0;       
        let file_name = "";
        let file_type = null;
        while(offset < this.buffer.byteLength - 512) {
            file_name = this._readFileName(offset); // file name
            if(file_name.length == 0) {
                break;
            }
            file_type = this._readFileType(offset);
            file_size = this._readFileSize(offset);

            this.fileInfo.push({
                "name": file_name,
                "type": file_type,
                "size": file_size,
                "header_offset": offset
            });

            offset += (512 + 512*Math.trunc(file_size/512));
            if(file_size % 512) {
                offset += 512;
            }
        }
    }

    getFileInfo() {
        return this.fileInfo;
    }

    _readString(str_offset, size) {
        let strView = new Uint8Array(this.buffer, str_offset, size);
        let i = strView.indexOf(0);
        let td = new TextDecoder();
        return td.decode(strView.slice(0, i));
    }

    _readFileName(header_offset) {
        let name = this._readString(header_offset, 100);
        return name;
    }

    _readFileType(header_offset) {
        // offset: 156
        let typeView = new Uint8Array(this.buffer, header_offset+156, 1);
        let typeStr = String.fromCharCode(typeView[0]);
        if(typeStr == "0") {
            return "file";
        } else if(typeStr == "5") {
            return "directory";
        } else {
            return typeStr;
        }
    }

    _readFileSize(header_offset) {
        // offset: 124
        let szView = new Uint8Array(this.buffer, header_offset+124, 12);
        let szStr = "";
        for(let i = 0; i < 11; i++) {
            szStr += String.fromCharCode(szView[i]);
        }
        return parseInt(szStr,8);
    }

    _readFileBlob(file_offset, size, mimetype) {
        let view = new Uint8Array(this.buffer, file_offset, size);
        let blob = new Blob([view], {"type": mimetype});
        return blob;
    }

    _readFileBinary(file_offset, size) {
        let view = new Uint8Array(this.buffer, file_offset, size);
        return view;
    }

    _readTextFile(file_offset, size) {
        let view = new Uint8Array(this.buffer, file_offset, size);
        let td = new TextDecoder();
        return td.decode(view);
    }

    getTextFile(file_name) {
        let info = this.fileInfo.find(info => info.name == file_name);
        if (info) {
            return this._readTextFile(info.header_offset+512, info.size); 
        }
    }

    getFileBlob(file_name, mimetype) {
        let info = this.fileInfo.find(info => info.name == file_name);
        if (info) {
            return this._readFileBlob(info.header_offset+512, info.size, mimetype); 
        }
    }

    getFileBinary(file_name) {
        let info = this.fileInfo.find(info => info.name == file_name);
        if (info) {
            return this._readFileBinary(info.header_offset+512, info.size); 
        }
    }
};

tarball.TarWriter = class {
    constructor() {
        this.fileData = [];
    }

    addTextFile(name, text, opts) {
        let te = new TextEncoder();
        let arr = te.encode(text);
        this.fileData.push({
            name: name,
            array: arr,
            type: "file",
            size: arr.length,
            dataType: "array",
            opts: opts
        });
    }

    addFileArrayBuffer(name, arrayBuffer, opts) {
        let arr = new Uint8Array(arrayBuffer);
        this.fileData.push({
            name: name,
            array: arr,
            type: "file",
            size: arr.length,
            dataType: "array",
            opts: opts
        });
    }

    addFile(name, file, opts) {
        this.fileData.push({
            name: name,
            file: file,
            size: file.size,
            type: "file",
            dataType: "file",
            opts: opts
        });
    }

    addFolder(name, opts) {
        this.fileData.push({
            name: name,
            type: "directory",
            size: 0,
            dataType: "none",
            opts: opts
        });
    }

    _createBuffer() {
        let tarDataSize = 0;
        for(let i = 0; i < this.fileData.length; i++) {                        
            let size = this.fileData[i].size;
            tarDataSize += 512 + 512*Math.trunc(size/512);
            if(size % 512) {
                tarDataSize += 512;
            }
        }
        let bufSize = 10240*Math.trunc(tarDataSize/10240);
        if(tarDataSize % 10240) {
            bufSize += 10240;
        }
        this.buffer = new ArrayBuffer(bufSize); 
    }

    async download(filename) {
        let blob = await this.writeBlob();
        let $downloadElem = document.createElement('a');
        $downloadElem.href = URL.createObjectURL(blob);
        $downloadElem.download = filename;
        $downloadElem.style.display = "none";
        document.body.appendChild($downloadElem);
        $downloadElem.click();
        document.body.removeChild($downloadElem);
    }

    async writeBlob(onUpdate) {
        return new Blob([await this.write(onUpdate)], {"type":"application/x-tar"});
    }

    write(onUpdate) {
        return new Promise((resolve,reject) => {
            this._createBuffer();
            let offset = 0;
            let filesAdded = 0;
            let onFileDataAdded = () => {
                filesAdded++;
                if (onUpdate) {
                    onUpdate(filesAdded / this.fileData.length * 100);
                }
                if(filesAdded === this.fileData.length) {
                    let arr = new Uint8Array(this.buffer);
                    resolve(arr);
                }
            };
            for(let fileIdx = 0; fileIdx < this.fileData.length; fileIdx++) {
                let fdata = this.fileData[fileIdx];
                // write header
                this._writeFileName(fdata.name, offset);
                this._writeFileType(fdata.type, offset);
                this._writeFileSize(fdata.size, offset);
                this._fillHeader(offset, fdata.opts, fdata.type);
                this._writeChecksum(offset);

                // write file data
                let destArray = new Uint8Array(this.buffer, offset+512, fdata.size);
                if(fdata.dataType === "array") {
                    for(let byteIdx = 0; byteIdx < fdata.size; byteIdx++) {
                        destArray[byteIdx] = fdata.array[byteIdx];
                    }
                    onFileDataAdded();
                } else if(fdata.dataType === "file") {
                    let reader = new FileReader();
                    
                    reader.onload = (function(outArray) {
                        let dArray = outArray;
                        return function(event) {
                            let sbuf = event.target.result;
                            let sarr = new Uint8Array(sbuf);
                            for(let bIdx = 0; bIdx < sarr.length; bIdx++) {
                                dArray[bIdx] = sarr[bIdx];
                            }
                            onFileDataAdded();
                        };
                    })(destArray);
                    reader.readAsArrayBuffer(fdata.file);
                } else if(fdata.type === "directory") {
                    onFileDataAdded();
                }

                offset += (512 + 512*Math.trunc(fdata.size/512));
                if(fdata.size % 512) {
                    offset += 512;
                }
            }
        });
    }

    _writeString(str, offset, size) {
        let strView = new Uint8Array(this.buffer, offset, size);
        let te = new TextEncoder();
        if (te.encodeInto) {
            // let the browser write directly into the buffer
            let written = te.encodeInto(str, strView).written;
            for (let i = written; i < size; i++) {
                strView[i] = 0;
            }
        } else {
            // browser can't write directly into the buffer, do it manually
            let arr = te.encode(str);
            for (let i = 0; i < size; i++) {
                strView[i] = i < arr.length ? arr[i] : 0;
            }
        }
    }

    _writeFileName(name, header_offset) {
        // offset: 0
        this._writeString(name, header_offset, 100);
    }

    _writeFileType(typeStr, header_offset) {
        // offset: 156
        let typeChar = "0";
        if(typeStr === "file") {
            typeChar = "0";
        } else if(typeStr === "directory") {
            typeChar = "5";
        }
        let typeView = new Uint8Array(this.buffer, header_offset + 156, 1);
        typeView[0] = typeChar.charCodeAt(0); 
    }

    _writeFileSize(size, header_offset) {
        // offset: 124
        let sz = size.toString(8);
        sz = this._leftPad(sz, 11);
        this._writeString(sz, header_offset+124, 12);
    }

    _leftPad(number, targetLength) {
        let output = number + '';
        while (output.length < targetLength) {
            output = '0' + output;
        }
        return output;
    }

    _writeFileMode(mode, header_offset) {
        // offset: 100
        this._writeString(this._leftPad(mode,7), header_offset+100, 8);         
    }

    _writeFileUid(uid, header_offset) {
        // offset: 108
        this._writeString(this._leftPad(uid,7), header_offset+108, 8);
    }
    
    _writeFileGid(gid, header_offset) {
        // offset: 116
        this._writeString(this._leftPad(gid,7), header_offset+116, 8);
    }

    _writeFileMtime(mtime, header_offset) {
        // offset: 136
        this._writeString(this._leftPad(mtime,11), header_offset+136, 12);
    }

    _writeFileUser(user, header_offset) {
        // offset: 265
        this._writeString(user, header_offset+265, 32);
    }
    
    _writeFileGroup(group, header_offset) {
        // offset: 297
        this._writeString(group, header_offset+297, 32);
    }

    _writeChecksum(header_offset) {
        // offset: 148
        this._writeString("        ", header_offset+148, 8); // first fill with spaces

        // add up header bytes
        let header = new Uint8Array(this.buffer, header_offset, 512);
        let chksum = 0;
        for(let i = 0; i < 512; i++) {
            chksum += header[i];
        }
        this._writeString(chksum.toString(8), header_offset+148, 8);
    }

    _getOpt(opts, opname, defaultVal) {
        if(opts != null) {
            if(opts[opname] != null) {
                return opts[opname];
            }
        }
        return defaultVal;
    }
    
    _fillHeader(header_offset, opts, fileType) {
        let uid = this._getOpt(opts, "uid", 1000);
        let gid = this._getOpt(opts, "gid", 1000);
        let mode = this._getOpt(opts, "mode", fileType === "file" ? "664" : "775");
        let mtime = this._getOpt(opts, "mtime", Date.now());
        let user = this._getOpt(opts, "user", "tarballjs");
        let group = this._getOpt(opts, "group", "tarballjs");

        this._writeFileMode(mode, header_offset);
        this._writeFileUid(uid.toString(8), header_offset);
        this._writeFileGid(gid.toString(8), header_offset);
        this._writeFileMtime(Math.trunc(mtime/1000).toString(8), header_offset);

        this._writeString("ustar", header_offset+257,6); // magic string
        this._writeString("00", header_offset+263,2); // magic version

        this._writeFileUser(user, header_offset);
        this._writeFileGroup(group, header_offset);
    }
};

