/*global Uint8Array:true ArrayBuffer:true */
"use strict";

import zlib from 'pako';
import { assert } from "../../../../../core/assert.js";
import { BinaryBuffer } from "../../../../../core/binary/BinaryBuffer.js";
import { EndianType } from "../../../../../core/binary/EndianType.js";
import { platform_compute_endianness } from "../../../../../core/binary/platform_compute_endianness.js";
import { isArrayEqualStrict } from "../../../../../core/collection/array/isArrayEqualStrict.js";
import { png_chunk_decode_iTXt } from "./chunk/png_chunk_decode_iTXt.js";
import { png_chunk_decode_zTXt } from "./chunk/png_chunk_decode_zTXt.js";
import { crc32 } from "./crc32.js";
import { png_filter_unFilterAverage } from "./filter/png_filter_unFilterAverage.js";
import { png_filter_unFilterNone } from "./filter/png_filter_unFilterNone.js";
import { png_filter_unFilterPaeth } from "./filter/png_filter_unFilterPaeth.js";
import { png_filter_unFilterSub } from "./filter/png_filter_unFilterSub.js";
import { png_filter_unFilterUp } from "./filter/png_filter_unFilterUp.js";

import { PNG } from './PNG.js';
import { PNG_HEADER_BYTES } from "./PNG_HEADER_BYTES.js";

/**
 *
 * @param {Uint8Array} buffer
 * @param {number} offset
 * @return {number}
 */
function readUInt32(buffer, offset) {
    return (buffer[offset] << 24)
        | (buffer[offset + 1] << 16)
        | (buffer[offset + 2] << 8)
        | (buffer[offset + 3]);
}


/**
 *
 * @param {Uint8Array} buffer
 * @param {number} offset
 * @return {number}
 */
function readUInt8(buffer, offset) {
    return buffer[offset];
}

/**
 *
 * @param {ArrayBuffer} bytes
 * @constructor
 */
export function PNGReader(bytes) {
    /**
     * current pointer
     * @type {number}
     */
    this.i = 0;

    /**
     * bytes buffer
     * @type {Uint8Array}
     */
    this.bytes = new Uint8Array(bytes);

    /**
     * Output object
     * @type {PNG}
     */
    this.png = new PNG();

    /**
     *
     * @type {Uint8Array[]}
     */
    this.dataChunks = [];

    /**
     *
     * @type {BinaryBuffer}
     */
    this.buffer = new BinaryBuffer();

    // see https://www.w3.org/TR/2003/REC-PNG-20031110/#7Integers-and-byte-order
    this.buffer.endianness = EndianType.BigEndian;

    this.buffer.fromArrayBuffer(bytes);

    /**
     * Whether CRC should be performed or not
     * @type {boolean}
     */
    this.crc_enabled = false;

    /**
     *
     * @type {Uint8Array}
     */
    this.header = new Uint8Array(8);
}

/**
 *
 * @param {number} length
 * @return {Uint8Array}
 */
PNGReader.prototype.readBytes = function (length) {

    const buffer = this.buffer;

    // Reference instead of a copy
    const result = new Uint8Array(buffer.data, buffer.position, length);

    buffer.skip(length);

    return result;

};

/**
 * http://www.w3.org/TR/2003/REC-PNG-20031110/#5PNG-file-signature
 */
PNGReader.prototype.decodeHeader = function () {

    if (this.i !== 0) {
        throw new Error('file pointer should be at 0 to read the header');
    }

    const buffer = this.buffer;

    const header = this.header;

    buffer.readBytes(header, 0, 8)

    // see https://www.w3.org/TR/PNG-Structure.html
    if (!isArrayEqualStrict(header, PNG_HEADER_BYTES)) {
        throw new Error('invalid PNGReader file (bad signature)');
    }


};

/**
 * http://www.w3.org/TR/2003/REC-PNG-20031110/#5Chunk-layout
 *
 * length =  4      bytes
 * type   =  4      bytes (IHDR, PLTE, IDAT, IEND or others)
 * chunk  =  length bytes
 * crc    =  4      bytes
 *
 * @returns {string} chunk type
 */
PNGReader.prototype.decodeChunk = function () {

    const buffer = this.buffer;

    const length = buffer.readUint32();

    if (length < 0) {
        throw new Error('Bad chunk length ' + (0xFFFFFFFF & length));
    }


    const chunk_address = buffer.position;

    const type = buffer.readASCIICharacters(4);

    /**
     *
     * @type {Uint8Array}
     */
    const chunk = this.readBytes(length);

    // checksum
    const crc_expected = buffer.readUint32();

    if (this.crc_enabled) {

        // NOTE: CRC includes the "type" tag, not just the chunk bytes
        const crc_actual = crc32(buffer.raw_bytes, chunk_address, length + 4);

        if (crc_actual !== crc_expected) {
            console.warn(`CRC (Cyclic Redundancy Check) error at block '${type}' at address ${chunk_address}`);
        }

    }

    // console.log(`chunk: ${type}`);


    switch (type) {
        case 'IHDR':
            this.decodeIHDR(chunk);
            break;
        case 'PLTE':
            this.decodePLTE(chunk);
            break;
        case 'IDAT':
            this.decodeIDAT(chunk);
            break;
        case 'tRNS':
            this.decodeTRNS(chunk);
            break;
        case 'IEND':
            this.decodeIEND(chunk);
            break;
        case 'sRGB':
            this.decodesRGB(chunk);
            break;
        case 'tIME':
            this.decodetIME(chunk);
            break;
        case 'zTXt':
            png_chunk_decode_zTXt(chunk);
            break;
        case 'iTXt':
            png_chunk_decode_iTXt(chunk);
            break;
        default:
            // skip unknown block
            // console.warn(`Unsupported block ${type}`);
            break;
    }

    return type;

};


/**
 * https://www.w3.org/TR/2003/REC-PNG-20031110/#11sRGB
 * @param {Uint8Array} chunk
 */
PNGReader.prototype.decodesRGB = function (chunk) {

    const rendering_intent = readUInt8(chunk, 0);

    // TODO add metadata to the PNG representation
}

/**
 * https://www.w3.org/TR/2003/REC-PNG-20031110/#11tIME
 * @param {Uint8Array} chunk
 */
PNGReader.prototype.decodetIME = function (chunk) {
    const year_high = readUInt8(chunk, 0);
    const year_low = readUInt8(chunk, 1);

    const year = (year_high << 8) | year_low;

    const month = readUInt8(chunk, 2);
    const day = readUInt8(chunk, 3);
    const hour = readUInt8(chunk, 4);
    const minute = readUInt8(chunk, 5);
    const second = readUInt8(chunk, 6);
}

/**
 * https://www.w3.org/TR/PNG/#11tEXt
 * @param {Uint8Array} chunk
 */
PNGReader.prototype.decodetEXt = function (chunk) {
    const buff = BinaryBuffer.fromArrayBuffer(chunk.buffer);
    const keyword = buff.readASCIICharacters(Number.POSITIVE_INFINITY, true);

    const value = buff.readASCIICharacters(keyword.length - 1, false);

    this.png.text[keyword] = value;
}
/**
 * NOTE: untested
 * https://www.w3.org/TR/PNG/#11iEXt
 * @param {Uint8Array} chunk
 */
PNGReader.prototype.decodeiEXt = function (chunk) {
    const buff = BinaryBuffer.fromArrayBuffer(chunk.buffer);

    const keyword = buff.readASCIICharacters(Number.POSITIVE_INFINITY, true);

    const compression_flag = buff.readUint8();
    const compression_method = buff.readUint8();

    // TODO process compression properly

    const language_tag = buff.readASCIICharacters(Number.POSITIVE_INFINITY, true);
    const translated_keyword = buff.readUTF8String();

    const separator = buff.readUint8();

    if (separator !== 0) {
        throw new Error('Expected Null Separator after Translated keyword');
    }

    const text = buff.readUTF8String();

    this.png.text[keyword] = text;
}

/**
 * http://www.w3.org/TR/2003/REC-PNG-20031110/#11IHDR
 * http://www.libpng.org/pub/png/spec/1.2/png-1.2-pdg.html#C.IHDR
 *
 * Width               4 bytes
 * Height              4 bytes
 * Bit depth           1 byte
 * Colour type         1 byte
 * Compression method  1 byte
 * Filter method       1 byte
 * Interlace method    1 byte
 */
PNGReader.prototype.decodeIHDR = function (chunk) {
    const png = this.png;

    png.setWidth(readUInt32(chunk, 0));
    png.setHeight(readUInt32(chunk, 4));
    png.setBitDepth(readUInt8(chunk, 8));
    png.setColorType(readUInt8(chunk, 9));
    png.setCompressionMethod(readUInt8(chunk, 10));
    png.setFilterMethod(readUInt8(chunk, 11));
    png.setInterlaceMethod(readUInt8(chunk, 12));

};

/**
 *
 * http://www.w3.org/TR/PNG/#11PLTE
 * @param {Uint8Array} chunk
 */
PNGReader.prototype.decodePLTE = function (chunk) {
    this.png.setPalette(chunk);
};

/**
 * http://www.w3.org/TR/2003/REC-PNG-20031110/#11IDAT
 */
PNGReader.prototype.decodeIDAT = function (chunk) {
    // multiple IDAT chunks will concatenated
    this.dataChunks.push(chunk);
};

/**
 * https://www.w3.org/TR/PNG/#11tRNS
 * @param {Uint8Array} chunk
 */
PNGReader.prototype.decodeTRNS = function (chunk) {
    this.png.transparency_lookup = chunk;
};

/**
 * http://www.w3.org/TR/2003/REC-PNG-20031110/#11IEND
 */
PNGReader.prototype.decodeIEND = function () {
};

/**
 * Uncompress IDAT chunks
 */
PNGReader.prototype.decodePixels = function () {
    const png = this.png;

    const chunks = this.dataChunks;
    const chunk_count = chunks.length;

    // First, try standard zlib-framed inflate. This verifies the adler32 trailer,
    // which is the right behaviour for spec-compliant PNGs.
    const inflator = new zlib.Inflate();
    for (let i = 0; i < chunk_count; i++) {
        inflator.push(chunks[i]);
    }

    let decompressed_data;

    if (inflator.err === 0) {
        decompressed_data = inflator.result;
    } else {
        // Some real-world encoders emit IDAT streams with a corrupt adler32 trailer
        // (the deflate payload itself is fine). Chrome and libpng accept these, so
        // we mirror that leniency: concatenate the IDAT bytes, strip the 2-byte
        // zlib header, and inflate as raw deflate (which doesn't check the trailer).
        let total = 0;
        for (let i = 0; i < chunk_count; i++) total += chunks[i].length;
        const concatenated = new Uint8Array(total);
        let off = 0;
        for (let i = 0; i < chunk_count; i++) {
            concatenated.set(chunks[i], off);
            off += chunks[i].length;
        }
        try {
            decompressed_data = zlib.inflateRaw(concatenated.subarray(2));
        } catch (raw_err) {
            // The deflate payload itself is broken; re-throw with the original
            // pako message for diagnostics.
            throw new Error(`Failed to inflate IDAT stream: ${inflator.msg || inflator.err}`);
        }
    }

    // https://www.w3.org/TR/png-3/#8InterlaceMethods
    if (png.getInterlaceMethod() === 0) {
        this.png.pixels = this.interlaceNone(decompressed_data);
    } else {
        this.png.pixels = this.interlaceAdam7(decompressed_data);
    }

    // PNG stores 16-bit samples big-endian. Rewrite the buffer to platform-endian
    // here so that downstream consumers can view it as Uint16Array without further
    // byte swapping.
    if (png.bitDepth === 16 && platform_compute_endianness() === EndianType.LittleEndian) {
        const pixels = this.png.pixels;
        for (let i = 0; i < pixels.length; i += 2) {
            const hi = pixels[i];
            pixels[i] = pixels[i + 1];
            pixels[i + 1] = hi;
        }
    }
};

// Different interlace methods

/**
 * @param {Uint8Array} data
 */
PNGReader.prototype.interlaceNone = function (data) {

    const png = this.png;

    const depth = png.bitDepth;
    const bits_per_line = png.colors * depth;
    const bytes_per_pixel = bits_per_line / 8;

    const width = png.width;
    const height = png.height;

    // color bytes per row
    const color_bytes_per_row = Math.ceil(bytes_per_pixel * width);

    const pixels = new Uint8Array(color_bytes_per_row * height);

    let offset = 0;

    const data_length = data.length;

    for (let i = 0; i < data_length; i += color_bytes_per_row + 1) {

        const scanline_address = i + 1;

        // scanline = slice.call(data, scanline_address, scanline_address + cpr);

        const filter_type = readUInt8(data, i);

        this.unFilter(
            filter_type,
            data,
            scanline_address,
            pixels,
            Math.max(1, Math.ceil(bytes_per_pixel)),
            offset,
            offset - color_bytes_per_row,
            color_bytes_per_row
        );

        offset += color_bytes_per_row;

    }

    return pixels;

};


/**
 * De-interlace image according to Adam 7 scheme
 * @param {Uint8Array} data
 */
PNGReader.prototype.interlaceAdam7 = function (data) {

    const png = this.png;

    const bytes_per_pixel = png.colors * png.bitDepth / 8;

    const pixels = new Uint8Array(bytes_per_pixel * png.width * png.height);

    // Adam7 interlacing pattern
    const passes = [
        { x: 0, y: 0, xStep: 8, yStep: 8 }, // Pass 1
        { x: 4, y: 0, xStep: 8, yStep: 8 }, // Pass 2
        { x: 0, y: 4, xStep: 4, yStep: 8 }, // Pass 3
        { x: 2, y: 0, xStep: 4, yStep: 4 }, // Pass 4
        { x: 0, y: 2, xStep: 2, yStep: 4 }, // Pass 5
        { x: 1, y: 0, xStep: 2, yStep: 2 }, // Pass 6
        { x: 0, y: 1, xStep: 1, yStep: 2 }, // Pass 7
    ];

    const width = png.width;
    const height = png.height;

    let offset = 0;

    // Create a new line for the unfiltered data
    const line_bytes_buffer = new Uint8Array(width * bytes_per_pixel);

    // Process each pass
    for (let passIndex = 0; passIndex < 7; passIndex++) {
        const pass = passes[passIndex];

        // Calculate pass dimensions
        const pass_width = Math.ceil((width - pass.x) / pass.xStep);
        const pass_height = Math.ceil((height - pass.y) / pass.yStep);

        if (pass_width <= 0 || pass_height <= 0) {
            continue;
        }

        const passLineBytes = pass_width * bytes_per_pixel;

        // Indicate to unfiltering that this is the first line (no previous line)
        let previous_line_offset = -1;

        // Process each scanline in this pass
        for (let y = 0; y < pass_height; y++) {

            // First byte is the filter type
            const filter_type = data[offset++];

            // we use alternating line offsets to save on memory allocations
            const line_offset = (y % 2) * passLineBytes;

            // Apply the appropriate unfilter
            this.unFilter(
                filter_type,
                data,
                offset,
                line_bytes_buffer,
                bytes_per_pixel,
                line_offset,
                previous_line_offset,
                passLineBytes,
            );

            offset += passLineBytes;
            previous_line_offset = line_offset;

            for (let x = 0; x < pass_width; x++) {

                const outputX = pass.x + x * pass.xStep;
                const outputY = pass.y + y * pass.yStep;

                if (outputX >= width || outputY >= height) {
                    continue;
                }

                for (let i = 0; i < bytes_per_pixel; i++) {
                    const out_offset = (outputY * width + outputX) * bytes_per_pixel + i;

                    pixels[out_offset] = line_bytes_buffer[line_offset + x * bytes_per_pixel + i];
                }
            }
        }
    }

    return pixels;
};

// Unfiltering

/**
 *
 * @param {number} filter_type
 * @param {Uint8Array} data
 * @param {number} scanline_address
 * @param {Uint8Array} output
 * @param {number} bytes_per_pixel
 * @param {number} output_offset
 * @param {number} output_offset_previous where does result of previous scanline begin in the output? Needed for various filters such as `Up`
 * @param {number} length
 *
 * @see https://www.w3.org/TR/PNG-Filters.html
 */
PNGReader.prototype.unFilter = function (
    filter_type,
    data,
    scanline_address,
    output,
    bytes_per_pixel,
    output_offset,
    output_offset_previous,
    length
) {
    assert.isInteger(filter_type, 'filter_type');
    assert.isNonNegativeInteger(scanline_address, 'scanline_address');
    assert.isNonNegativeInteger(bytes_per_pixel, 'bytes_per_pixel');
    assert.isNonNegativeInteger(output_offset, 'output_offset');
    assert.isNonNegativeInteger(length, 'length');

    switch (filter_type) {
        case 0:
            png_filter_unFilterNone(
                data,
                scanline_address,
                output,
                output_offset,
                length,
                this.png.bitDepth
            );
            break;
        case 1:
            png_filter_unFilterSub(
                data,
                scanline_address,
                output,
                bytes_per_pixel,
                output_offset,
                length
            );
            break;
        case 2:
            png_filter_unFilterUp(
                data,
                scanline_address,
                output,
                output_offset,
                output_offset_previous,
                length
            );
            break;
        case 3:
            png_filter_unFilterAverage(
                data,
                scanline_address,
                output,
                bytes_per_pixel,
                output_offset,
                output_offset_previous,
                length
            );
            break;
        case 4:
            png_filter_unFilterPaeth(
                data,
                scanline_address,
                output,
                bytes_per_pixel,
                output_offset,
                output_offset_previous,
                length
            );
            break;
        default:
            throw new Error(`unknown filtered scanline type '${filter_type}'`);
    }
}

/**
 * Parse the PNG file
 * @returns {PNG}
 */
PNGReader.prototype.parse = function () {

    this.decodeHeader();

    for (; ;) {

        const type = this.decodeChunk();

        if (type === 'IEND') {
            // reached the end
            break;
        }

    }

    this.decodePixels();

    return this.png;

};
