/++
	Support for [https://wiki.mozilla.org/APNG_Specification|animated png] files.

	History:
		Originally written March 2019 with read support.

		Render support added December 28, 2020.
+/
module arsd.apng;

///
unittest {
	import arsd.simpledisplay;
	import arsd.game;
	import arsd.apng;

	void main(string[] args) {
		import std.file;
		auto a = readApng(cast(ubyte[]) std.file.read(args[1]));

		auto window = create2dWindow("Animated PNG viewer", a.header.width, a.header.height);

		auto render = a.renderer();
		OpenGlTexture[] frames;
		int[] waits;
		foreach(frame; a.frames) {
			waits ~= render.nextFrame();
			// this would be the raw data for the frame
			//frames ~= new OpenGlTexture(frame.frameData.getAsTrueColorImage);
			// or the current rendered ersion
			frames ~= new OpenGlTexture(render.buffer);
		}

		int pos;
		int currentWait;

		void update() {
			currentWait += waits[pos];
			pos++;
			if(pos == frames.length)
				pos = 0;
		}

		window.redrawOpenGlScene = () {
			glClear(GL_COLOR_BUFFER_BIT);
			frames[pos].draw(0, 0);
		};

		auto tick = 50;
		window.eventLoop(tick, delegate() {
			currentWait -= tick;
			auto updateNeeded = currentWait <= 0;
			while(currentWait <= 0)
				update();
			if(updateNeeded)
				window.redrawOpenGlSceneNow();
		//},
		//(KeyEvent ev) {
		//if(ev.pressed)
		});
	}

	version(Demo) main(["", "/home/me/test/apngexample.apng"]); // remove from docs
}

import arsd.png;

// acTL
// must be in the file before the IDAT
struct AnimationControlChunk {
	uint num_frames;
	uint num_plays;
}

// fcTL
struct FrameControlChunk {
	align(1):
	// this should go up each time, for frame control AND for frame data, each increases.
	uint sequence_number;
	uint width;
	uint height;
	uint x_offset;
	uint y_offset;
	ushort delay_num;
	ushort delay_den;
	APNG_DISPOSE_OP dispose_op;
	APNG_BLEND_OP blend_op;

	static assert(dispose_op.offsetof == 24);
	static assert(blend_op.offsetof == 25);
}

// fdAT
class ApngFrame {

	ApngAnimation parent;

	this(ApngAnimation parent) {
		this.parent = parent;
	}

	FrameControlChunk frameControlChunk;

	ubyte[] compressedDatastream;

	ubyte[] data;
	MemoryImage frameData;
	void populateData() {
		if(data !is null)
			return;

		import std.zlib;

		auto raw = cast(ubyte[]) uncompress(compressedDatastream);
		auto bpp = bytesPerPixel(parent.header);

		auto width = frameControlChunk.width;
		auto height = frameControlChunk.height;

		auto bytesPerLine = bytesPerLineOfPng(parent.header.depth, parent.header.type, width);
		bytesPerLine--; // removing filter byte from this calculation since we handle separately

		size_t idataIdx;
		ubyte[] idata;

		MemoryImage img;
		if(parent.header.type == 3) {
			auto i = new IndexedImage(width, height);
			img = i;
			i.palette = parent.palette;
			idata = i.data;
		} else {
			auto i = new TrueColorImage(width, height);
			img = i;
			idata = i.imageData.bytes;
		}

		immutable(ubyte)[] previousLine;
		foreach(y; 0 .. height) {
			auto filter = raw[0];
			raw = raw[1 .. $];
			auto line = raw[0 .. bytesPerLine];
			raw = raw[bytesPerLine .. $];

			auto unfiltered = unfilter(filter, line, previousLine, bpp);
			previousLine = unfiltered;

			convertPngData(parent.header.type, parent.header.depth, unfiltered, width, idata, idataIdx);
		}

		this.data = idata;
		this.frameData = img;
	}
}

struct ApngRenderBuffer {
	ApngAnimation animation;

	public TrueColorImage buffer;
	public int frameNumber;

	private FrameControlChunk prevFcc;
	private TrueColorImage[] convertedFrames;
	private TrueColorImage previousFrame;

	/++
		Returns number of millisecond to wait until the next frame.
	+/
	int nextFrame() {
		if(frameNumber == animation.frames.length) {
			frameNumber = 0;
			prevFcc = FrameControlChunk.init;
		}

		auto frame = animation.frames[frameNumber];
		auto fcc = frame.frameControlChunk;
		if(convertedFrames is null) {
			convertedFrames = new TrueColorImage[](animation.frames.length);
		}
		if(convertedFrames[frameNumber] is null) {
			frame.populateData();
			convertedFrames[frameNumber] = frame.frameData.getAsTrueColorImage();
		}

		final switch(prevFcc.dispose_op) {
			case APNG_DISPOSE_OP.NONE:
				break;
			case APNG_DISPOSE_OP.BACKGROUND:
				// clear area to 0
				foreach(y; prevFcc.y_offset .. prevFcc.y_offset + prevFcc.height)
					buffer.imageData.bytes[
						4 * (prevFcc.x_offset + y * buffer.width)
						..
						4 * (prevFcc.x_offset + prevFcc.width + y * buffer.width)
					] = 0;
				break;
			case APNG_DISPOSE_OP.PREVIOUS:
				// put the buffer back in

				// this could prolly be more efficient, it only really cares about the prevFcc bounding box
				buffer.imageData.bytes[] = previousFrame.imageData.bytes[];
				break;
		}

		prevFcc = fcc;
		// should copy the buffer at this point for a PREVIOUS case happening
		if(fcc.dispose_op == APNG_DISPOSE_OP.PREVIOUS) {
			// this could prolly be more efficient, it only really cares about the prevFcc bounding box
			if(previousFrame is null){
				previousFrame = buffer.clone();
			} else {
				previousFrame.imageData.bytes[] = buffer.imageData.bytes[];
			}
		}

		size_t foff;
		foreach(y; fcc.y_offset .. fcc.y_offset + fcc.height) {
			final switch(fcc.blend_op) {
				case APNG_BLEND_OP.SOURCE:
					buffer.imageData.bytes[
						4 * (fcc.x_offset + y * buffer.width)
						..
						4 * (fcc.x_offset + y * buffer.width + fcc.width)
					] = convertedFrames[frameNumber].imageData.bytes[foff .. foff + fcc.width * 4];
					foff += fcc.width * 4;
				break;
				case APNG_BLEND_OP.OVER:
					foreach(x; fcc.x_offset .. fcc.x_offset + fcc.width) {
						buffer.imageData.colors[y * buffer.width + x] =
							alphaBlend(
								convertedFrames[frameNumber].imageData.colors[foff],
								buffer.imageData.colors[y * buffer.width + x]
							);
						foff++;
					}
				break;
			}
		}

		frameNumber++;

		return fcc.delay_num * 1000 / fcc.delay_den;
	}
}

class ApngAnimation {
	PngHeader header;
	AnimationControlChunk acc;
	Color[] palette;
	ApngFrame[] frames;
	// default image? tho i can just load it as a png for that too.

	ApngRenderBuffer renderer() {
		return ApngRenderBuffer(this, new TrueColorImage(header.width, header.height), 0);
	}
}

enum APNG_DISPOSE_OP : byte {
	NONE = 0,
	BACKGROUND = 1,
	PREVIOUS = 2
}

enum APNG_BLEND_OP : byte {
	SOURCE = 0,
	OVER = 1
}

/++
	Loads an apng file.

	If it is a normal png file without animation it will
	just load it as a single frame "animation" FIXME
+/
ApngAnimation readApng(in ubyte[] data) {
	auto png = readPng(data);
	auto header = PngHeader.fromChunk(png.chunks[0]);

	auto obj = new ApngAnimation();
	obj.header = header;

	if(header.type == 3) {
		obj.palette = fetchPalette(png);
	}

	bool seenIdat = false;
	bool seenFctl = false;

	int frameNumber;
	int expectedSequenceNumber = 0;

	foreach(chunk; png.chunks) {
		switch(chunk.stype) {
			case "IDAT":
				seenIdat = true;
				// all I care about here are animation frames,
				// so if this isn't after a control chunk, I'm
				// just going to ignore it. Read the file with
				// readPng if you want that.
				if(!seenFctl)
					continue;

				assert(frameNumber == 1); // we work on frame 0 but fcTL advances it
				assert(obj.frames[0]);

				obj.frames[0].compressedDatastream ~= chunk.payload;
			break;
			case "acTL":
				AnimationControlChunk c;
				int offset = 0;
				c.num_frames |= chunk.payload[offset++] << 24;
				c.num_frames |= chunk.payload[offset++] << 16;
				c.num_frames |= chunk.payload[offset++] <<  8;
				c.num_frames |= chunk.payload[offset++] <<  0;

				c.num_plays |= chunk.payload[offset++] << 24;
				c.num_plays |= chunk.payload[offset++] << 16;
				c.num_plays |= chunk.payload[offset++] <<  8;
				c.num_plays |= chunk.payload[offset++] <<  0;

				assert(offset == chunk.payload.length);

				obj.acc = c;
				obj.frames = new ApngFrame[](c.num_frames);
			break;
			case "fcTL":
				FrameControlChunk c;
				int offset = 0;

				seenFctl = true;

				c.sequence_number |= chunk.payload[offset++] << 24;
				c.sequence_number |= chunk.payload[offset++] << 16;
				c.sequence_number |= chunk.payload[offset++] <<  8;
				c.sequence_number |= chunk.payload[offset++] <<  0;

				c.width |= chunk.payload[offset++] << 24;
				c.width |= chunk.payload[offset++] << 16;
				c.width |= chunk.payload[offset++] <<  8;
				c.width |= chunk.payload[offset++] <<  0;

				c.height |= chunk.payload[offset++] << 24;
				c.height |= chunk.payload[offset++] << 16;
				c.height |= chunk.payload[offset++] <<  8;
				c.height |= chunk.payload[offset++] <<  0;

				c.x_offset |= chunk.payload[offset++] << 24;
				c.x_offset |= chunk.payload[offset++] << 16;
				c.x_offset |= chunk.payload[offset++] <<  8;
				c.x_offset |= chunk.payload[offset++] <<  0;

				c.y_offset |= chunk.payload[offset++] << 24;
				c.y_offset |= chunk.payload[offset++] << 16;
				c.y_offset |= chunk.payload[offset++] <<  8;
				c.y_offset |= chunk.payload[offset++] <<  0;

				c.delay_num |= chunk.payload[offset++] <<  8;
				c.delay_num |= chunk.payload[offset++] <<  0;

				c.delay_den |= chunk.payload[offset++] <<  8;
				c.delay_den |= chunk.payload[offset++] <<  0;

				c.dispose_op = cast(APNG_DISPOSE_OP) chunk.payload[offset++];
				c.blend_op = cast(APNG_BLEND_OP) chunk.payload[offset++];

				assert(offset == chunk.payload.length);

				if(expectedSequenceNumber != c.sequence_number)
					throw new Exception("malformed apng file");

				expectedSequenceNumber++;


				if(obj.frames[frameNumber] is null)
					obj.frames[frameNumber] = new ApngFrame(obj);
				obj.frames[frameNumber].frameControlChunk = c;

				frameNumber++;
			break;
			case "fdAT":
				uint sequence_number;
				int offset;

				sequence_number |= chunk.payload[offset++] << 24;
				sequence_number |= chunk.payload[offset++] << 16;
				sequence_number |= chunk.payload[offset++] <<  8;
				sequence_number |= chunk.payload[offset++] <<  0;

				if(expectedSequenceNumber != sequence_number)
					throw new Exception("malformed apng file");

				expectedSequenceNumber++;

				// and the rest of it is a datastream...
				obj.frames[frameNumber - 1].compressedDatastream ~= chunk.payload[offset .. $];
			break;
			default:
				// ignore
		}

	}

	return obj;
}