module std::compression::qoi;

const uint PIXELS_MAX = 400000000;

<*
 Colorspace.
 Purely informative. It will be saved to the file header,
 but does not affect how chunks are en-/decoded.
*>
enum QOIColorspace : char (char id)
{
	SRGB = 0, 	// sRGB with linear alpha
	LINEAR = 1 	// all channels linear
}

<*
 Channels.
 The channels used in an image.
 AUTO can be used when decoding to automatically determine
 the channels from the file's header.
*>
enum QOIChannels : char (char id)
{
	AUTO = 0,
	RGB = 3,
	RGBA = 4
}

<*
 Descriptor.
 Contains information about an image.
*>
struct QOIDesc
{
	uint width;
	uint height;
	QOIChannels channels;
	QOIColorspace colorspace;
}
<*
 QOI Errors.
 These are all the possible bad outcomes.
*>
faultdef INVALID_PARAMETERS, FILE_OPEN_FAILED, FILE_WRITE_FAILED, INVALID_DATA, TOO_MANY_PIXELS;


// Let the user decide if they want to use std::io
module std::compression::qoi @if(!$feature(QOI_NO_STDIO));
import std::io;

<*
 Encode raw RGB or RGBA pixels into a QOI image and write it to the
 file system.

 The desc struct must be filled with the image width, height, the
 used channels (QOIChannels.RGB or RGBA) and the colorspace
 (QOIColorspace.SRGB or LINEAR).

 The function returns an optional, which can either be a QOIError
 or the number of bytes written on success.

 @param [in] filename : `The file's name to write the image to`
 @param [in] input : `The raw RGB or RGBA pixels to encode`
 @param [&in] desc : `The descriptor of the image`
*>
fn usz? write(String filename, char[] input, QOIDesc* desc) => @pool()
{
	// encode data
	char[] output = encode(tmem, input, desc)!;

	file::save(filename, output)!;
	return output.len;
}


<*
 Read and decode a QOI image from the file system.

 If channels is set to QOIChannels.AUTO, the function will
 automatically determine the channels from the file's header.
 However, if channels is RGB or RGBA, the output format will be
 forced into this number of channels.

 The desc struct will be filled with the width, height,
 channels and colorspace of the image.

 The function returns an optional, which can either be a QOIError
 or a char[] pointing to the decoded pixels on success.

 The returned pixel data should be free()d after use, or the decoding
 and use of the data should be wrapped in a @pool() { ... }; block.

 @param [in] filename : `The file's name to read the image from`
 @param [&out] desc : `The descriptor to fill with the image's info`
 @param channels : `The channels to be used`
 @return? FILE_OPEN_FAILED, INVALID_DATA, TOO_MANY_PIXELS
*>
fn char[]? read(Allocator allocator, String filename, QOIDesc* desc, QOIChannels channels = AUTO) => @pool()
{
	// read file
	char[] data = file::load_temp(filename) ?? FILE_OPEN_FAILED?!;
	// pass data to decode function
	return decode(allocator, data, desc, channels);
}



// Back to basic non-stdio mode
module std::compression::qoi;
import std::bits;

<*
 Encode raw RGB or RGBA pixels into a QOI image in memory.

 The function returns an optional, which can either be a QOIError
 or a char[] pointing to the encoded data on success.

 The returned qoi data should be free()d after use, or the encoding
 and use of the data should be wrapped in a @pool() { ... }; block.
 See the write() function for an example.

 @param [in] input : `The raw RGB or RGBA pixels to encode`
 @param [&in] desc : `The descriptor of the image`
 @return? INVALID_PARAMETERS, TOO_MANY_PIXELS, INVALID_DATA
*>
fn char[]? encode(Allocator allocator, char[] input, QOIDesc* desc) @nodiscard
{
	// check info in desc
	if (desc.width == 0 || desc.height == 0) return INVALID_PARAMETERS?;
	if (desc.channels == AUTO) return INVALID_PARAMETERS?;
	uint pixels = desc.width * desc.height;
	if (pixels > PIXELS_MAX) return TOO_MANY_PIXELS?;
	
	// check input data size
	uint image_size = pixels * desc.channels.id;
	if (image_size != input.len) return INVALID_DATA?;

	// allocate memory for encoded data (output)
	// header + chunk tag and RGB(A) data for each pixel + end of stream
	uint max_size = Header.sizeof + pixels + image_size + END_OF_STREAM.len;
	char[] output = allocator::alloc_array(allocator, char, max_size); // no need to init
	defer catch allocator::free(allocator, output);

	// write header
	*(Header*)output.ptr = {
		.be_magic = bswap('qoif'),
		.be_width = bswap(desc.width),
		.be_height = bswap(desc.height),
		.channels = desc.channels.id,
		.colorspace = desc.colorspace.id
	};

	uint pos = Header.sizeof; 	// Current position in output
	uint loc;					// Current position in image (top-left corner)
	uint loc_end = image_size - desc.channels.id; // End of image data
	char run_length = 0; 		// Length of the current run
	
	Pixel[64] palette; // Zero-initialized by default
	Pixel prev = { 0, 0, 0, 255 };
	Pixel p = { 0, 0, 0, 255 };

	ichar[<3>] diff; // pre-allocate for diff
	ichar[<3>] luma; // ...and luma

	// write chunks
	for (loc = 0; loc < image_size; loc += desc.channels.id)
	{
		// set previous pixel
		prev = p;

		// get current pixel
		p[:3] = input[loc:3]; // cutesy slices :3
		if (desc.channels == RGBA) p.a = input[loc + 3];

		// check if we can run the previous pixel
		if (prev == p)
		{
			run_length++;
			if (run_length == 62 || loc == loc_end)
			{
				*@extract(OpRun, output, &pos) = { OP_RUN, run_length - 1 };
				run_length = 0;
			}
			continue;
		}
		// end last run if there was one
		if (run_length > 0)
		{
			*@extract(OpRun, output, &pos) = { OP_RUN, run_length - 1 };
			run_length = 0;
		}

		switch
		{
			// check if we can index the palette
			case (palette[p.hash()] == p):
				*@extract(OpIndex, output, &pos) = {
					OP_INDEX,
					p.hash()
				};

			// check if we can use diff or luma
			case (prev != p && prev.a == p.a):
				// diff the pixels
				diff = p.rgb - prev.rgb;
				if (diff.r > -3 && diff.r < 2
					&& diff.g > -3 && diff.g < 2
					&& diff.b > -3 && diff.b < 2)
				{
					*@extract(OpDiff, output, &pos) = {
						OP_DIFF,
						(char)diff.r + 2,
						(char)diff.g + 2,
						(char)diff.b + 2
					};
					palette[p.hash()] = p;
					break;
				}
				// check luma eligibility
				luma = { diff.r - diff.g, diff.g, diff.b - diff.g };
				if (luma.r >= -8 && luma.r <= 7
					&& luma.g >= -32 && luma.g <= 31
					&& luma.b >= -8 && luma.b <= 7)
				{
					*@extract(OpLuma, output, &pos) = {
						OP_LUMA,
						(char)luma.g + 32,
						(char)luma.r + 8,
						(char)luma.b + 8
					};
					palette[p.hash()] = p;
					break;
				}
				nextcase;

			// worst case scenario: just encode the raw pixel
			default:
				if (prev.a != p.a)
				{
					*@extract(OpRGBA, output, &pos) = { OP_RGBA, p.r, p.g, p.b, p.a };
				}
				else
				{
					*@extract(OpRGB, output, &pos) = { OP_RGB, p.r, p.g, p.b };
				}
				palette[p.hash()] = p;
		}
	}

	// write end of stream
	output[pos:END_OF_STREAM.len] = END_OF_STREAM[..];
	pos += END_OF_STREAM.len;
	
	return output[:pos];
}



<*
 Decode a QOI image from memory.

 If channels is set to QOIChannels.AUTO, the function will
 automatically determine the channels from the file's header.
 However, if channels is RGB or RGBA, the output format will be
 forced into this number of channels.

 The desc struct will be filled with the width, height,
 channels and colorspace of the image.

 The function returns an optional, which can either be a QOIError
 or a char[] pointing to the decoded pixels on success.

 The returned pixel data should be free()d after use, or the decoding
 and use of the data should be wrapped in a @pool() { ... }; block.

 @param [in] data : `The QOI image data to decode`
 @param [&out] desc : `The descriptor to fill with the image's info`
 @param channels : `The channels to be used`
 @return? INVALID_DATA, TOO_MANY_PIXELS
*>
fn char[]? decode(Allocator allocator, char[] data, QOIDesc* desc, QOIChannels channels = AUTO) @nodiscard
{
	// check input data
	if (data.len < Header.sizeof + END_OF_STREAM.len) return INVALID_DATA?;

	// get header
	Header* header = (Header*)data.ptr;

	// check magic bytes (FourCC)
	if (bswap(header.be_magic) != 'qoif') return INVALID_DATA?;

	// copy header data to desc
	desc.width = bswap(header.be_width);
	desc.height = bswap(header.be_height);
	desc.channels = @enumcast(QOIChannels, header.channels)!; 			// Rethrow if invalid
	desc.colorspace = @enumcast(QOIColorspace, header.colorspace)!;	// Rethrow if invalid
	if (desc.channels == AUTO) return INVALID_DATA?; // Channels must be specified in the header

	// check width and height
	if (desc.width == 0 || desc.height == 0) return INVALID_DATA?;

	// check pixel count
	ulong pixels = (ulong)desc.width * (ulong)desc.height;
	if (pixels > PIXELS_MAX) return TOO_MANY_PIXELS?;

	uint pos = Header.sizeof; 	// Current position in data
	uint loc;					// Current position in image (top-left corner)
	char run_length = 0; 		// Length of the current run
	char tag;					// Current chunk tag

	Pixel[64] palette; // Zero-initialized by default
	Pixel p = { 0, 0, 0, 255 };

	if (channels == AUTO) channels = desc.channels;
	
	// allocate memory for image data
	usz image_size = (usz)pixels * channels.id;
	char[] image = allocator::alloc_array(allocator, char, image_size);
	defer catch allocator::free(allocator, image);

	for (loc = 0; loc < image_size; loc += channels.id)
	{
		// get chunk tag
		tag = data[pos];

		// check for chunk type
		switch
		{
			case run_length > 0:
				run_length--;

			case tag == OP_RGB:
				OpRGB* op = @extract(OpRGB, data, &pos);
				p = { op.red, op.green, op.blue, p.a };
				palette[p.hash()] = p;

			case tag == OP_RGBA:
				OpRGBA* op = @extract(OpRGBA, data, &pos);
				p = { op.red, op.green, op.blue, op.alpha };
				palette[p.hash()] = p;
				
			case tag >> 6 == OP_INDEX:
				OpIndex* op = @extract(OpIndex, data, &pos);
				p = palette[op.index];

			case tag >> 6 == OP_DIFF:
				OpDiff* op = @extract(OpDiff, data, &pos);
				p.r += op.diff_red - 2;
				p.g += op.diff_green - 2;
				p.b += op.diff_blue - 2;
				palette[p.hash()] = p;

			case tag >> 6 == OP_LUMA:
				OpLuma* op = @extract(OpLuma, data, &pos);
				int diff_green = op.diff_green - 32;
				p.r += (char)(op.diff_red_minus_green - 8 + diff_green);
				p.g += (char)(diff_green);
				p.b += (char)(op.diff_blue_minus_green - 8 + diff_green);
				palette[p.hash()] = p;

			case tag >> 6 == OP_RUN:
				OpRun* op = @extract(OpRun, data, &pos);
				run_length = op.run;
		}

		// draw the pixel
		if (channels == RGBA) { image[loc:4] = p.rgba[..]; } else { image[loc:3] = p.rgb[..]; }
	}
	
	return image;
}



// ***************************************************************************
// ***                                                                     ***
// ***    Main functions are at the top to make the file more readable.    ***
// ***        From here on, helper functions and types are defined.        ***
// ***                                                                     ***
// ***************************************************************************
module std::compression::qoi @private;

// 8-bit opcodes
const OP_RGB = 0b11111110;
const OP_RGBA = 0b11111111;
// 2-bit opcodes
const OP_INDEX = 0b00;
const OP_DIFF = 0b01;
const OP_LUMA = 0b10;
const OP_RUN = 0b11;

struct Header @packed
{
	uint be_magic;	// magic bytes "qoif"
	uint be_width;	// image width in pixels (BE)
	uint be_height;	// image height in pixels (BE)

	// informative fields
	char channels;	// 3 = RGB, 4 = RGB
	char colorspace;	// 0 = sRGB with linear alpha, 1 = all channels linear
}

const char[*] END_OF_STREAM = {0, 0, 0, 0, 0, 0, 0, 1};

// inefficient, but it's only run once at a time

<*
 @return? INVALID_DATA
*>
macro @enumcast($Type, raw)
{
	foreach (value : $Type.values)
	{
		if (value.id == raw) return value;
	}
	return INVALID_DATA?;
}

typedef Pixel = inline char[<4>];
macro char Pixel.hash(Pixel p)
{
	return (p.r * 3 + p.g * 5 + p.b * 7 + p.a * 11) % 64;
}

struct OpRGB // No need to use @packed here, the alignment is 1 anyways.
{
	char tag;
	char red;
	char green;
	char blue;
}
struct OpRGBA @packed
{
	char tag;
	char red;
	char green;
	char blue;
	char alpha;
}
bitstruct OpIndex : char
{
	char tag : 6..7;
	char index : 0..5;
}
bitstruct OpDiff : char
{
	char tag : 6..7;
	char diff_red : 4..5;
	char diff_green : 2..3;
	char diff_blue : 0..1;
}
bitstruct OpLuma : ushort @align(1)
{
	char tag : 6..7;
	char diff_green : 0..5;
	char diff_red_minus_green : 12..15;
	char diff_blue_minus_green : 8..11;
}
bitstruct OpRun : char
{
	char tag : 6..7;
	char run : 0..5;
}

// Macro used to locate chunks in data buffers.
// Can be used both for reading and writing.
macro @extract($Type, char[] data, uint* pos)
{
	// slice data, then double cast
	$Type* chunk = ($Type*)data[*pos : $Type.sizeof].ptr;
	*pos += $Type.sizeof;
	return chunk;
}
