/*
 * Copyright 2014 Mozilla Foundation
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { Settings } from '../Settings';
import { NativeDeflate } from './NativeDeflate';
import { IDataDecoder, memCopy } from './utilities';

enum InflateState {
	INIT = 0,
	BLOCK_0 = 1,
	BLOCK_1 = 2,
	BLOCK_2_PRE = 3,
	BLOCK_2 = 4,
	DONE = 5,
	ERROR = 6,
	VERIFY_HEADER = 7
}

const WINDOW_SIZE = 32768;
const WINDOW_SHIFT_POSITION = 65536;
const MAX_WINDOW_SIZE = WINDOW_SHIFT_POSITION + 258; /* plus max copy len */

interface HuffmanTable {
	codes: Uint32Array;
	maxBits: number;
}

interface DeflateCopyState {
	state: number;
	len: number;
	lenBits: number;
	dist: number;
	distBits: number;
}

interface DeflateBlock2State {
	numLiteralCodes: number;
	numDistanceCodes: number;
	codeLengthTable: HuffmanTable;
	bitLengths: Uint8Array;
	codesRead: number;
	dupBits: number;
}

export class Inflate implements IDataDecoder {
	public onData: (buffer: Uint8Array) => void;
	public onError: (e) => void;

	constructor(verifyHeader: boolean) {
		//
	}

	public push(data: Uint8Array) {
		//Debug.abstractMethod('Inflate.push');
	}

	public close() {
		//
	}

	public static create(
		verifyHeader: boolean,
		size: number = 0,
		tryNative = true): IDataDecoder
	// eslint-disable-next-line brace-style
	{

		if (tryNative && !NativeDeflate.isSupported) {
			console.warn('[NativeDeflate] Is not supported!');
		}

		if (tryNative && NativeDeflate.isSupported && Settings.USE_NATIVE_DEFLATE) {
			if (size) {
				//console.debug('[NativeDeflate] Decoding API is supported and enabled, use native');
				return new NativeDeflate(verifyHeader, size);
			} else {
				console.debug('[NativeDeflate] size not presented, can`t use a native implementation');
			}
		}

		return new BasicInflate(verifyHeader);
	}

	_processZLibHeader(buffer: Uint8Array, start: number, end: number): number {
		/* returns -1 - bad header, 0 - not enough data, 1+ - number of bytes processed */
		const ZLIB_HEADER_SIZE = 2;
		if (start + ZLIB_HEADER_SIZE > end) {
			return 0;
		}
		const header = (buffer[start] << 8) | buffer[start + 1];
		let error: string = null;
		if ((header & 0x0f00) !== 0x0800) {
			error = 'inflate: unknown compression method';
		} else if ((header % 31) !== 0) {
			error = 'inflate: bad FCHECK';
		} else if ((header & 0x20) !== 0) {
			error = 'inflate: FDICT bit set';
		}
		if (error) {
			if (this.onError) {
				this.onError(error);
			}
			return -1;
		} else {
			return ZLIB_HEADER_SIZE;
		}
	}

	public static inflate(
		data: Uint8Array,
		expectedLength: number,
		zlibHeader: boolean): Uint8Array {

		let position = 0;

		const output = new Uint8Array(expectedLength);
		const inflate = Inflate.create(zlibHeader, 0, false);

		inflate.onData = function (data) {
			// Make sure we don't cause an exception here when trying to set out-of-bound data by clamping the number of
			// bytes to write to the remaining space in our output buffer. The Flash Player ignores data that goes over the
			// expected length, so should we.
			const length = Math.min(data.length, output.length - position);
			if (length) {
				memCopy(output, data, position, 0, length);
			}
			position += length;
		};
		inflate.onError = function (error) {
			throw new Error(error);
		};
		inflate.push(data);
		inflate.close();
		return output;
	}
}

class BasicInflate extends Inflate {
	private _buffer: Uint8Array;
	private _bufferSize: number;
	private _bufferPosition: number;
	private _bitBuffer: number;
	private _bitLength: number;
	private _window: Uint8Array;
	private _windowPosition: number;
	private _state: InflateState;
	private _isFinalBlock: boolean;
	private _literalTable: HuffmanTable;
	private _distanceTable: HuffmanTable;
	private _block0Read: number;
	private _block2State: DeflateBlock2State;
	private _copyState: DeflateCopyState;

	constructor(verifyHeader: boolean) {
		super(verifyHeader);
		this._buffer = null;
		this._bufferSize = 0;
		this._bufferPosition = 0;
		this._bitBuffer = 0;
		this._bitLength = 0;
		this._window = new Uint8Array(MAX_WINDOW_SIZE);
		this._windowPosition = 0;
		this._state = verifyHeader ? InflateState.VERIFY_HEADER : InflateState.INIT;
		this._isFinalBlock = false;
		this._literalTable = null;
		this._distanceTable = null;
		this._block0Read = 0;
		this._block2State = null;
		this._copyState = {
			state: 0,
			len: 0,
			lenBits: 0,
			dist: 0,
			distBits: 0
		};

		if (!areTablesInitialized) {
			initializeTables();
			areTablesInitialized = true;
		}
	}

	public push(data: Uint8Array) {
		if (!this._buffer || this._buffer.length < this._bufferSize + data.length) {
			const newBuffer = new Uint8Array(this._bufferSize + data.length);
			if (this._buffer) {
				newBuffer.set(this._buffer);
			}
			this._buffer = newBuffer;
		}
		this._buffer.set(data, this._bufferSize);
		this._bufferSize += data.length;
		this._bufferPosition = 0;

		let incomplete = false;
		do {
			const lastPosition = this._windowPosition;
			if (this._state === InflateState.INIT) {
				incomplete = this._decodeInitState();
				if (incomplete) {
					break;
				}
			}

			switch (this._state) {
				case InflateState.BLOCK_0:
					incomplete = this._decodeBlock0();
					break;
				case InflateState.BLOCK_2_PRE:
					incomplete = this._decodeBlock2Pre();
					if (incomplete) {
						break;
					}
					/* fall through */
				case InflateState.BLOCK_1:
				case InflateState.BLOCK_2:
					incomplete = this._decodeBlock();
					break;
				case InflateState.ERROR:
				case InflateState.DONE:
					// skipping all data
					this._bufferPosition = this._bufferSize;
					break;
				case InflateState.VERIFY_HEADER:
					var processed = this._processZLibHeader(this._buffer, this._bufferPosition, this._bufferSize);
					if (processed > 0) {
						this._bufferPosition += processed;
						this._state = InflateState.INIT;
					} else if (processed === 0) {
						incomplete = true;
					} else {
						this._state = InflateState.ERROR;
					}
					break;
			}

			const decoded = this._windowPosition - lastPosition;
			if (decoded > 0) {
				this.onData(this._window.subarray(lastPosition, this._windowPosition));
			}
			if (this._windowPosition >= WINDOW_SHIFT_POSITION) {
				// shift window
				if ('copyWithin' in this._buffer) {
					this._window['copyWithin'](0, this._windowPosition - WINDOW_SIZE, this._windowPosition);
				} else {
					this._window.set(this._window.subarray(this._windowPosition - WINDOW_SIZE,
						this._windowPosition));
				}
				this._windowPosition = WINDOW_SIZE;
			}
		} while (!incomplete && this._bufferPosition < this._bufferSize);

		if (this._bufferPosition < this._bufferSize) {
			// shift buffer
			if ('copyWithin' in this._buffer as any) {
				this._buffer['copyWithin'](0, this._bufferPosition, this._bufferSize);
			} else {
				this._buffer.set(this._buffer.subarray(this._bufferPosition, this._bufferSize));
			}
			this._bufferSize -= this._bufferPosition;
		} else {
			this._bufferSize = 0;
		}
	}

	private _decodeInitState(): boolean {
		if (this._isFinalBlock) {
			this._state = InflateState.DONE;
			return false;
		}

		const buffer = this._buffer, bufferSize = this._bufferSize;
		let bitBuffer = this._bitBuffer, bitLength = this._bitLength;
		let state;
		let position = this._bufferPosition;
		if (((bufferSize - position) << 3) + bitLength < 3) {
			return true;
		}
		if (bitLength < 3) {
			bitBuffer |= buffer[position++] << bitLength;
			bitLength += 8;
		}
		const type = bitBuffer & 7;
		bitBuffer >>= 3;
		bitLength -= 3;
		switch (type >> 1) {
			case 0:
				bitBuffer = 0;
				bitLength = 0;
				if (bufferSize - position < 4) {
					return true;
				}

				var length = buffer[position] | (buffer[position + 1] << 8);
				var length2 = buffer[position + 2] | (buffer[position + 3] << 8);
				position += 4;
				if ((length ^ length2) !== 0xFFFF) {
					this._error('inflate: invalid block 0 length');
					state = InflateState.ERROR;
					break;
				}

				if (length === 0) {
					state = InflateState.INIT;
				} else {
					this._block0Read = length;
					state = InflateState.BLOCK_0;
				}
				break;
			case 1:
				state = InflateState.BLOCK_1;
				this._literalTable = fixedLiteralTable;
				this._distanceTable = fixedDistanceTable;
				break;
			case 2:
				if (((bufferSize - position) << 3) + bitLength < 14 + 3 * 4) {
					return true;
				}
				while (bitLength < 14) {
					bitBuffer |= buffer[position++] << bitLength;
					bitLength += 8;
				}
				var numLengthCodes = ((bitBuffer >> 10) & 15) + 4;
				if (((bufferSize - position) << 3) + bitLength < 14 + 3 * numLengthCodes) {
					return true;
				}
				var block2State: DeflateBlock2State = {
					numLiteralCodes: (bitBuffer & 31) + 257,
					numDistanceCodes: ((bitBuffer >> 5) & 31) + 1,
					codeLengthTable: undefined,
					bitLengths: undefined,
					codesRead: 0,
					dupBits: 0
				};
				bitBuffer >>= 14;
				bitLength -= 14;
				var codeLengths = new Uint8Array(19);
				for (var i = 0; i < numLengthCodes; ++i) {
					if (bitLength < 3) {
						bitBuffer |= buffer[position++] << bitLength;
						bitLength += 8;
					}
					codeLengths[codeLengthOrder[i]] = bitBuffer & 7;
					bitBuffer >>= 3;
					bitLength -= 3;
				}
				for (; i < 19; i++) {
					codeLengths[codeLengthOrder[i]] = 0;
				}
				block2State.bitLengths =
            new Uint8Array(block2State.numLiteralCodes + block2State.numDistanceCodes);
				block2State.codeLengthTable = makeHuffmanTable(codeLengths);
				this._block2State = block2State;
				state = InflateState.BLOCK_2_PRE;
				break;
			default:
				this._error('inflate: unsupported block type');
				state = InflateState.ERROR;
				return false;
		}
		this._isFinalBlock = !!(type & 1);
		this._state = state;
		this._bufferPosition = position;
		this._bitBuffer = bitBuffer;
		this._bitLength = bitLength;
		return false;
	}

	private _error(e: string) {
		if (this.onError) {
			this.onError(e);
		}
	}

	private _decodeBlock0() {
		const position = this._bufferPosition;
		const windowPosition = this._windowPosition;
		const toRead = this._block0Read;
		const leftInWindow = MAX_WINDOW_SIZE - windowPosition;
		const leftInBuffer = this._bufferSize - position;
		const incomplete = leftInBuffer < toRead;
		const canFit = Math.min(leftInWindow, leftInBuffer, toRead);
		this._window.set(this._buffer.subarray(position, position + canFit),
			windowPosition);
		this._windowPosition = windowPosition + canFit;
		this._bufferPosition = position + canFit;
		this._block0Read = toRead - canFit;

		if (toRead === canFit) {
			this._state = InflateState.INIT;
			return false;
		}

		return incomplete && leftInWindow < leftInBuffer;
	}

	private _readBits(size) {
		let bitBuffer = this._bitBuffer;
		let bitLength = this._bitLength;
		if (size > bitLength) {
			let pos = this._bufferPosition;
			const end = this._bufferSize;
			do {
				if (pos >= end) {
					this._bufferPosition = pos;
					this._bitBuffer = bitBuffer;
					this._bitLength = bitLength;
					return -1;
				}
				bitBuffer |= this._buffer[pos++] << bitLength;
				bitLength += 8;
			} while (size > bitLength);
			this._bufferPosition = pos;
		}
		this._bitBuffer = bitBuffer >> size;
		this._bitLength = bitLength - size;
		return bitBuffer & ((1 << size) - 1);
	}

	private _readCode(codeTable) {
		let bitBuffer = this._bitBuffer;
		let bitLength = this._bitLength;
		const maxBits = codeTable.maxBits;
		if (maxBits > bitLength) {
			let pos = this._bufferPosition;
			const end = this._bufferSize;
			do {
				if (pos >= end) {
					this._bufferPosition = pos;
					this._bitBuffer = bitBuffer;
					this._bitLength = bitLength;
					return -1;
				}
				bitBuffer |= this._buffer[pos++] << bitLength;
				bitLength += 8;
			} while (maxBits > bitLength);
			this._bufferPosition = pos;
		}

		const code = codeTable.codes[bitBuffer & ((1 << maxBits) - 1)];
		const len = code >> 16;
		if ((code & 0x8000)) {
			this._error('inflate: invalid encoding');
			this._state = InflateState.ERROR;
			return -1;
		}

		this._bitBuffer = bitBuffer >> len;
		this._bitLength = bitLength - len;
		return code & 0xffff;
	}

	private _decodeBlock2Pre() {
		const block2State = this._block2State;
		const numCodes = block2State.numLiteralCodes + block2State.numDistanceCodes;
		const bitLengths = block2State.bitLengths;
		let i = block2State.codesRead;
		let prev = i > 0 ? bitLengths[i - 1] : 0;
		const codeLengthTable = block2State.codeLengthTable;
		var j;
		if (block2State.dupBits > 0) {
			j = this._readBits(block2State.dupBits);
			if (j < 0) {
				return true;
			}
			while (j--) {
				bitLengths[i++] = prev;
			}
			block2State.dupBits = 0;
		}
		while (i < numCodes) {
			let sym = this._readCode(codeLengthTable);
			if (sym < 0) {
				block2State.codesRead = i;
				return true;
			} else if (sym < 16) {
				bitLengths[i++] = (prev = sym);
				continue;
			}
			var j, dupBits;
			switch (sym) {
				case 16:
					dupBits = 2;
					j = 3;
					sym = prev;
					break;
				case 17:
					dupBits = 3;
					j = 3;
					sym = 0;
					break;
				case 18:
					dupBits = 7;
					j = 11;
					sym = 0;
					break;
			}
			while (j--) {
				bitLengths[i++] = sym;
			}
			j = this._readBits(dupBits);
			if (j < 0) {
				block2State.codesRead = i;
				block2State.dupBits = dupBits;
				return true;
			}
			while (j--) {
				bitLengths[i++] = sym;
			}
			prev = sym;
		}
		this._literalTable = makeHuffmanTable(bitLengths.subarray(0, block2State.numLiteralCodes));
		this._distanceTable = makeHuffmanTable(bitLengths.subarray(block2State.numLiteralCodes));
		this._state = InflateState.BLOCK_2;
		this._block2State = null;
		return false;
	}

	private _decodeBlock(): boolean {
		const literalTable = this._literalTable, distanceTable = this._distanceTable;
		let output = this._window, pos = this._windowPosition;
		const copyState = this._copyState;
		let i: number, j: number, sym: number;
		let len: number, lenBits: number, dist: number, distBits: number;

		if (copyState.state !== 0) {
			// continuing len/distance operation
			switch (copyState.state) {
				case 1:
					j = 0;
					if ((j = this._readBits(copyState.lenBits)) < 0) {
						return true;
					}
					copyState.len += j;
					copyState.state = 2;
					/* fall through */
				case 2:
					if ((sym = this._readCode(distanceTable)) < 0) {
						return true;
					}
					copyState.distBits = distanceExtraBits[sym];
					copyState.dist = distanceCodes[sym];
					copyState.state = 3;
					/* fall through */
				case 3:
					j = 0;
					if (copyState.distBits > 0 && (j = this._readBits(copyState.distBits)) < 0) {
						return true;
					}
					dist = copyState.dist + j;
					len = copyState.len;
					i = pos - dist;
					while (len--) {
						output[pos++] = output[i++];
					}
					copyState.state = 0;
					if (pos >= WINDOW_SHIFT_POSITION) {
						this._windowPosition = pos;
						return false;
					}
					break;
			}
		}

		do {
			sym = this._readCode(literalTable);
			if (sym < 0) {
				this._windowPosition = pos;
				return true;
			} else if (sym < 256) {
				output[pos++] = sym;
			} else if (sym > 256) {
				this._windowPosition = pos;
				sym -= 257;
				lenBits = lengthExtraBits[sym];
				len = lengthCodes[sym];
				j = lenBits === 0 ? 0 : this._readBits(lenBits);
				if (j < 0) {
					copyState.state = 1;
					copyState.len = len;
					copyState.lenBits = lenBits;
					return true;
				}
				len += j;
				sym = this._readCode(distanceTable);
				if (sym < 0) {
					copyState.state = 2;
					copyState.len = len;
					return true;
				}
				distBits = distanceExtraBits[sym];
				dist = distanceCodes[sym];
				j = distBits === 0 ? 0 : this._readBits(distBits);
				if (j < 0) {
					copyState.state = 3;
					copyState.len = len;
					copyState.dist = dist;
					copyState.distBits = distBits;
					return true;
				}
				dist += j;
				i = pos - dist;
				while (len--) {
					output[pos++] = output[i++];
				}
			} else {
				this._state = InflateState.INIT;
				break; // end of block
			}
		} while (pos < WINDOW_SHIFT_POSITION);
		this._windowPosition = pos;
		return false;
	}
}

let codeLengthOrder: Uint8Array;
let distanceCodes: Uint16Array;
let distanceExtraBits: Uint8Array;
let fixedDistanceTable: HuffmanTable;
let lengthCodes: Uint16Array;
let lengthExtraBits: Uint8Array;
let fixedLiteralTable: HuffmanTable;

var areTablesInitialized: boolean = false;

function initializeTables() {
	codeLengthOrder = new Uint8Array([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);

	distanceCodes = new Uint16Array(30);
	distanceExtraBits = new Uint8Array(30);
	for (var i = 0, j = 0, code = 1; i < 30; ++i) {
		distanceCodes[i] = code;
		code += 1 << (distanceExtraBits[i] = ~~((j += (i > 2 ? 1 : 0)) / 2));
	}

	const bitLengths = new Uint8Array(288);
	for (var i = 0; i < 32; ++i) {
		bitLengths[i] = 5;
	}
	fixedDistanceTable = makeHuffmanTable(bitLengths.subarray(0, 32));

	lengthCodes = new Uint16Array(29);
	lengthExtraBits = new Uint8Array(29);
	for (var i = 0, j = 0, code = 3; i < 29; ++i) {
		lengthCodes[i] = code - (i == 28 ? 1 : 0);
		code += 1 << (lengthExtraBits[i] = ~~(((j += (i > 4 ? 1 : 0)) / 4) % 6));
	}
	for (var i = 0; i < 288; ++i) {
		bitLengths[i] = i < 144 || i > 279 ? 8 : (i < 256 ? 9 : 7);
	}
	fixedLiteralTable = makeHuffmanTable(bitLengths);
}

function makeHuffmanTable(bitLengths: Uint8Array): HuffmanTable {
	const maxBits = Math.max.apply(null, bitLengths);
	const numLengths = bitLengths.length;
	const size = 1 << maxBits;
	const codes = new Uint32Array(size);
	// avoiding len == 0: using max number of bits
	const dummyCode = (maxBits << 16) | 0xFFFF;
	for (let j = 0; j < size; j++) {
		codes[j] = dummyCode;
	}
	for (let code = 0, len = 1, skip = 2; len <= maxBits; code <<= 1, ++len, skip <<= 1) {
		for (let val = 0; val < numLengths; ++val) {
			if (bitLengths[val] === len) {
				let lsb = 0;
				for (var i = 0; i < len; ++i)
					lsb = (lsb * 2) + ((code >> i) & 1);
				for (var i = lsb; i < size; i += skip)
					codes[i] = (len << 16) | val;
				++code;
			}
		}
	}
	return { codes: codes, maxBits: maxBits };
}

enum DeflateState {
	WRITE = 0,
	DONE = 1,
	ZLIB_HEADER = 2,
}

export class Adler32 {
	private a: number;
	private b: number;

	constructor() {
		this.a = 1;
		this.b = 0;
	}

	public update(data: Uint8Array, start: number, end: number) {
		let a = this.a;
		let b = this.b;
		for (let i = start; i < end; ++i) {
			a = (a + (data[i] & 0xff)) % 65521;
			b = (b + a) % 65521;
		}
		this.a = a;
		this.b = b;
	}

	public getChecksum(): number {
		return (this.b << 16) | this.a;
	}
}

export class Deflate implements IDataDecoder {
	public onData: (data: Uint8Array) => void;
	public onError: (e) => void;

	private _writeZlibHeader: boolean;
	private _state: DeflateState;
	private _adler32: Adler32;

	constructor(writeZlibHeader: boolean) {
		this._writeZlibHeader = writeZlibHeader;
		this._state = writeZlibHeader ? DeflateState.ZLIB_HEADER : DeflateState.WRITE;
		this._adler32 = writeZlibHeader ? new Adler32() : null;
	}

	public push(data: Uint8Array) {
		if (this._state === DeflateState.ZLIB_HEADER) {
			this.onData(new Uint8Array([0x78, 0x9C]));
			this._state = DeflateState.WRITE;
		}
		// simple non-compressing algorithm for now
		let len = data.length;
		const outputSize = len + Math.ceil(len / 0xFFFF) * 5;
		const output = new Uint8Array(outputSize);
		let outputPos = 0;
		let pos = 0;
		while (len > 0xFFFF) {
			output.set(new Uint8Array([
				0x00,
				0xFF, 0xFF,
				0x00, 0x00
			]), outputPos);
			outputPos += 5;
			output.set(data.subarray(pos, pos + 0xFFFF), outputPos);
			pos += 0xFFFF;
			outputPos += 0xFFFF;
			len -= 0xFFFF;
		}

		output.set(new Uint8Array([
			0x00,
			(len & 0xff), ((len >> 8) & 0xff),
			((~len) & 0xff), (((~len) >> 8) & 0xff)
		]), outputPos);
		outputPos += 5;
		output.set(data.subarray(pos, len), outputPos);

		this.onData(output);

		if (this._adler32) {
			this._adler32.update(data, 0, len);
		}
	}

	public close() {
		this._state = DeflateState.DONE;
		this.onData(new Uint8Array([
			0x01,
			0x00, 0x00,
			0xFF, 0xFF
		]));
		if (this._adler32) {
			const checksum = this._adler32.getChecksum();
			this.onData(new Uint8Array([
				checksum & 0xff, (checksum >> 8) & 0xff,
				(checksum >> 16) & 0xff, (checksum >>> 24) & 0xff
			]));
		}
	}
}
