@tool
class_name AImgIOAPNGImporter
extends RefCounted
## Will NOT import regular, unanimated PNGs - use Image.load_png_from_buffer
## This is because we don't want to import the default image as a frame
## Therefore it just uses the rule:
##  "fcTL chunk always precedes an APNG frame, even if that includes IDAT"


## Imports an APNG PoolByteArray into an animation as an Array of frames.
## Returns [error, frames] similar to some read functions.
## However, error is a string.
static func load_from_buffer(buffer: PackedByteArray) -> Array:
	var stream := AImgIOAPNGStream.new(buffer)
	var magic_str = stream.read_magic()
	if magic_str != null:
		# well, that was a nope
		return [magic_str, null]
	# Ok, so, before we continue, let's establish what is and is not okay
	#  for the target of this importer.

	# Firstly, thankfully, we don't have to worry about colour profiles or any
	#  ancillary chunks that involve them.
	# Godot doesn't care and caring would break images meant for non-colour use.
	# If for some reason you care and consider this a hot take:
	#  + Just perceptual-intent sRGB at display driver / hardware level.
	#  + Anyone who really cares about colourimetric intent should NOT use RGB.
	# (This said, the gAMA chunk has its uses in realistic usecases.
	#  Having a way to specify linear vs. non-linear RGB isn't inherently bad.)
	# Secondly, we DO have to worry about tRNS.
	# tRNS is an "optional" chunk to support.
	# ...same as fcTL is "optional".
	# The file would decode but actual meaningful data is lost.
	# Thirdly, the size of an APNG frame is not necessarily the original size.
	# So to convert an APNG frame to a PNG for reading, we need to stitch:
	# IHDR (modified), PLTE (if present), tRNS (if present), IDAT (from fdAT),
	#  and IEND (generated).
	var ihdr := PackedByteArray()
	var plte := PackedByteArray()
	var trns := PackedByteArray()
	# stored full width/height for buffer
	var width := 0
	var height := 0
	# parse chunks
	var frames: Array[BFrame] = []
	while stream.read_chunk() == OK:
		if stream.chunk_type == "IHDR":
			ihdr = stream.chunk_data
			# extract necessary information
			if len(ihdr) < 8:
				return ["IHDR not even large enough for W/H", null]
			var sp := StreamPeerBuffer.new()
			sp.data_array = ihdr
			sp.big_endian = true
			width = sp.get_32()
			height = sp.get_32()
		elif stream.chunk_type == "PLTE":
			plte = stream.chunk_data
		elif stream.chunk_type == "tRNS":
			trns = stream.chunk_data
		elif stream.chunk_type == "fcTL":
			var f := BFrame.new()
			var err = f.setup(stream.chunk_data)
			if err != null:
				return [err, null]
			frames.push_back(f)
		elif stream.chunk_type == "IDAT":
			# add to last frame if any
			# this uses the lack of the fcTL for the default image on purpose,
			# while still handling frame 0 as IDAT properly
			if len(frames) > 0:
				var f: BFrame = frames[len(frames) - 1]
				f.add_data(stream.chunk_data)
		elif stream.chunk_type == "fdAT":
			# it's just frame data
			# we ignore seq. nums. if they're wrong, file's invalid
			# so if there are issues, we don't have to support them
			if len(frames) > 0:
				var f: BFrame = frames[len(frames) - 1]
				if len(stream.chunk_data) >= 4:
					var data := stream.chunk_data.slice(4, len(stream.chunk_data))
					f.add_data(data)
	# theoretically we *could* store the default frame somewhere, but *why*?
	# just use Image functions if you want that
	if len(frames) == 0:
		return ["No frames", null]
	# prepare initial operating buffer
	var operating := Image.create(width, height, false, Image.FORMAT_RGBA8)
	operating.fill(Color(0, 0, 0, 0))
	var finished: Array[AImgIOFrame] = []
	for v in frames:
		var fv: BFrame = v
		# Ok, so to avoid having to deal with filters and stuff,
		#  what we do here is generate intermediary single-frame PNG files.
		# Whoever specced APNG either managed to make a good format by accident,
		#  or had a very good understanding of the concerns of people who need
		#  to retrofit their PNG decoders into APNG decoders, because the fact
		#  you can even do this is *beautiful*.
		var intermediary := fv.intermediary(ihdr, plte, trns)
		var intermediary_img := Image.new()
		if intermediary_img.load_png_from_buffer(intermediary) != OK:
			return ["error during intermediary load - corrupt/bug?", null]
		intermediary_img.convert(Image.FORMAT_RGBA8)
		# dispose vars
		var blit_target := operating
		var copy_blit_target := true
		# rectangles and such
		var blit_src := Rect2i(Vector2i.ZERO, intermediary_img.get_size())
		var blit_pos := Vector2i(fv.x, fv.y)
		var blit_tgt := Rect2i(blit_pos, intermediary_img.get_size())
		# early dispose ops
		if fv.dispose_op == 2:
			# previous
			# we handle this by never actually writing to the operating buffer,
			#  but instead a copy (so we don't have to make another later)
			blit_target = Image.new()
			blit_target.copy_from(operating)
			copy_blit_target = false
		# actually blit
		if blit_src.size != Vector2i.ZERO:
			if fv.blend_op == 0:
				blit_target.blit_rect(intermediary_img, blit_src, blit_pos)
			else:
				blit_target.blend_rect(intermediary_img, blit_src, blit_pos)
		# insert as frame
		var ffin := AImgIOFrame.new()
		ffin.duration = fv.duration
		if copy_blit_target:
			var img := Image.new()
			img.copy_from(operating)
			ffin.content = img
		else:
			ffin.content = blit_target
		finished.push_back(ffin)
		# late dispose ops
		if fv.dispose_op == 1:
			# background
			# this works as you expect
			operating.fill_rect(blit_tgt, Color(0, 0, 0, 0))
	return [null, finished]


## Imports an APNG file into an animation as an array of frames.
## Returns null on error.
static func load_from_file(path: String) -> Array:
	var o := FileAccess.open(path, FileAccess.READ)
	if o == null:
		return [null, "Unable to open file: " + path]
	var l = o.get_length()
	var data = o.get_buffer(l)
	o.close()
	return load_from_buffer(data)


## Intermediate frame structure. Quite internal, too, so don't treat it as API.
class BFrame:
	extends RefCounted
	## fcTL dispose op
	var dispose_op: int
	## fcTL blend op
	var blend_op: int
	## fcTL rectangle X
	var x: int
	## fcTL rectangle Y
	var y: int
	## fcTL rectangle width
	var w: int
	## fcTL rectangle height
	var h: int
	## Duration in seconds
	var duration: float
	## Pixel data, effectively the IDAT chunk for the intermediate PNG
	var data: PackedByteArray

	## Sets up this instance given an fcTL chunk.
	func setup(fctl: PackedByteArray):
		if len(fctl) < 26:
			return ""
		var sp := StreamPeerBuffer.new()
		sp.data_array = fctl
		sp.big_endian = true
		sp.get_32()
		w = sp.get_32() & 0xFFFFFFFF
		h = sp.get_32() & 0xFFFFFFFF
		# theoretically these are supposed to be unsigned, but like...
		# that just contributes to the assertion of it being inbounds, really.
		# so since blitting will do the crop anyway, let's just be generous
		x = sp.get_32()
		y = sp.get_32()
		var num := float(sp.get_16() & 0xFFFF)
		var den := float(sp.get_16() & 0xFFFF)
		if den == 0.0:
			den = 100
		duration = num / den
		dispose_op = sp.get_8()
		blend_op = sp.get_8()
		return null

	## Creates an intermediary PNG.
	## This can be loaded by Godot directly.
	## This basically skips most of the APNG decoding process.
	func intermediary(
		ihdr: PackedByteArray, plte: PackedByteArray, trns: PackedByteArray
	) -> PackedByteArray:
		# Might be important to note this operates on a copy of ihdr (by-value).
		var sp := StreamPeerBuffer.new()
		sp.data_array = ihdr
		sp.big_endian = true
		sp.put_32(w)
		sp.put_32(h)
		var intermed := AImgIOAPNGStream.new()
		intermed.write_magic()
		intermed.write_chunk("IHDR", sp.data_array)
		if len(plte) > 0:
			intermed.write_chunk("PLTE", plte)
		if len(trns) > 0:
			intermed.write_chunk("tRNS", trns)
		intermed.write_chunk("IDAT", data)
		intermed.write_chunk("IEND", PackedByteArray())
		return intermed.finish()

	## Adds data (this is done to merge in IDAT/fdAT content)
	func add_data(d: PackedByteArray):
		data.append_array(d)
