
local color = require("grapes.Color")
local Filesystem = require("grapes.Filesystem")
local unicode = require("unicode")
local bit32 = require("bit32")

-- Grapes filesystem initialization

local component = require("component")
local bootFilesystemProxy = component.proxy(component.invoke(component.list("eeprom")(), "getData"))
Filesystem.setProxy(bootFilesystemProxy)

--------------------------------------------------------------------------------

local image = {}

local OCIFSignature = "OCIF"
local encodingMethodsRead = {}
local encodingMethodsReadMetadata = {}
local encodingMethodsWrite = {}

--------------------------------------------------------------------------------

local function group(picture, compressColors)
	local groupedPicture, x, y, background, foreground = {}, 1, 1

	for i = 3, #picture, 4 do
		if compressColors then
			background, foreground = color.to8Bit(picture[i]), color.to8Bit(picture[i + 1])

			if i % 603 == 0 then
				computer.pullSignal(0)
			end
		else
			background, foreground = picture[i], picture[i + 1]
		end

		groupedPicture[picture[i + 2]] = groupedPicture[picture[i + 2]] or {}
		groupedPicture[picture[i + 2]][picture[i + 3]] = groupedPicture[picture[i + 2]][picture[i + 3]] or {}
		groupedPicture[picture[i + 2]][picture[i + 3]][background] = groupedPicture[picture[i + 2]][picture[i + 3]][background] or {}
		groupedPicture[picture[i + 2]][picture[i + 3]][background][foreground] = groupedPicture[picture[i + 2]][picture[i + 3]][background][foreground] or {}
		groupedPicture[picture[i + 2]][picture[i + 3]][background][foreground][y] = groupedPicture[picture[i + 2]][picture[i + 3]][background][foreground][y] or {}

		table.insert(groupedPicture[picture[i + 2]][picture[i + 3]][background][foreground][y], x)

		x = x + 1

		if x > picture[1] then
			x, y = 1, y + 1
		end
	end

	return groupedPicture
end

-- 5
encodingMethodsWrite[5] = function(file, picture)
	file:writeBytes(
		bit32.rshift(picture[1], 8),
		bit32.band(picture[1], 0xFF)
	)

	file:writeBytes(
		bit32.rshift(picture[2], 8),
		bit32.band(picture[2], 0xFF)
	)

	for i = 3, #picture, 4 do
		file:writeBytes(
			color.to8Bit(picture[i]),
			color.to8Bit(picture[i + 1]),
			math.floor(picture[i + 2] * 255)
		)

		file:write(picture[i + 3])
	end
end

encodingMethodsReadMetadata[5] = function(file)
	return
		file:readBytes(2),
		file:readBytes(2)
end

encodingMethodsRead[5] = function(file, picture, width, height)
	picture[1] = width
	picture[2] = height

	for i = 1, width * height do
		table.insert(picture, color.to24Bit(file:readBytes(1)))
		table.insert(picture, color.to24Bit(file:readBytes(1)))
		table.insert(picture, file:readBytes(1) / 255)
		table.insert(picture, file:readUnicodeChar())
	end
end

local function readMetadata678(file, is7, is8)
	return
		file:readBytes(1) + is8,
		file:readBytes(1) + is8
end

local function read678(file, picture, width, height, is7, is8)
	picture[1] = width
	picture[2] = height

	local currentAlpha, currentSymbol, currentBackground, currentForeground, currentY

	for alpha = 1, file:readBytes(1) + is7 do
		currentAlpha = file:readBytes(1) / 255
		
		for symbol = 1, file:readBytes(2) + is7 do
			currentSymbol = file:readUnicodeChar()
			
			for background = 1, file:readBytes(1) + is7 do
				currentBackground = color.to24Bit(file:readBytes(1))
				
				for foreground = 1, file:readBytes(1) + is7 do
					currentForeground = color.to24Bit(file:readBytes(1))
					
					for y = 1, file:readBytes(1) + is7 do
						currentY = file:readBytes(1)
						
						for x = 1, file:readBytes(1) + is7 do
							image.set(
								picture,
								file:readBytes(1) + is8,
								currentY + is8,
								currentBackground,
								currentForeground,
								currentAlpha,
								currentSymbol
							)
						end
					end
				end
			end
		end
	end
end

local function saveOCIF678(file, picture, is7, is8)
	local function getGroupSize(t)
		local size = -is7
		
		for key in pairs(t) do
			size = size + 1
		end
    
		return size
	end
	
	-- Grouping picture by it's alphas, symbols and colors
	local groupedPicture = group(picture, true)

	-- Writing 1 byte per image width and height
	file:writeBytes(
		picture[1] - is8,
		picture[2] - is8
	)

	-- Writing 1 byte for alphas array size
	file:writeBytes(getGroupSize(groupedPicture))

	local symbolsSize

	for alpha in pairs(groupedPicture) do
		symbolsSize = getGroupSize(groupedPicture[alpha])

		file:writeBytes(
			-- Writing 1 byte for current alpha value
			math.floor(alpha * 255),
			-- Writing 2 bytes for symbols array size
			bit32.rshift(symbolsSize, 8),
			bit32.band(symbolsSize, 0xFF)
		)

		for symbol in pairs(groupedPicture[alpha]) do
			-- Writing current unicode symbol value
			file:write(symbol)
			-- Writing 1 byte for backgrounds array size
			file:writeBytes(getGroupSize(groupedPicture[alpha][symbol]))

			for background in pairs(groupedPicture[alpha][symbol]) do
				file:writeBytes(
					-- Writing 1 byte for background color value (compressed by color)
					background,
					-- Writing 1 byte for foregrounds array size
					getGroupSize(groupedPicture[alpha][symbol][background])
				)

				for foreground in pairs(groupedPicture[alpha][symbol][background]) do
					file:writeBytes(
						-- Writing 1 byte for foreground color value (compressed by color)
						foreground,
						-- Writing 1 byte for y array size
						getGroupSize(groupedPicture[alpha][symbol][background][foreground])
					)
					
					for y in pairs(groupedPicture[alpha][symbol][background][foreground]) do
						file:writeBytes(
							-- Writing 1 byte for current y value
							y - is8,
							-- Writing 1 byte for x array size
							#groupedPicture[alpha][symbol][background][foreground][y] - is7
						)

						for x = 1, #groupedPicture[alpha][symbol][background][foreground][y] do
							-- Wrting 1 byte for current x value
							file:writeBytes(groupedPicture[alpha][symbol][background][foreground][y][x] - is8)
						end
					end
				end
			end
		end
	end
end

-- 6
encodingMethodsReadMetadata[6] = function(file)
	return readMetadata678(file, 0, 0)
end

encodingMethodsRead[6] = function(file, picture, width, height)
	read678(file, picture, width, height, 0, 0)
end

encodingMethodsWrite[6] = function(file, picture)
	saveOCIF678(file, picture, 0, 0)
end

-- 7
encodingMethodsReadMetadata[7] = function(file)
	return readMetadata678(file, 1, 0)
end

encodingMethodsRead[7] = function(file, picture, width, height)
	read678(file, picture, width, height, 1, 0)
end

encodingMethodsWrite[7] = function(file, picture)
	saveOCIF678(file, picture, 1, 0)
end

-- 8
encodingMethodsReadMetadata[8] = function(file)
	return readMetadata678(file, 1, 1)
end

encodingMethodsRead[8] = function(file, picture, width, height)
	read678(file, picture, width, height, 1, 1)
end

encodingMethodsWrite[8] = function(file, picture)
	saveOCIF678(file, picture, 1, 1)
end

--------------------------------------------------------------------------------

function image.getIndex(x, y, width)
	return 4 * (width * (y - 1) + x) - 1
end

function image.create(width, height, background, foreground, alpha, symbol, random)
	local picture = {width, height}

	for i = 1, width * height do
		table.insert(picture, random and math.random(0x0, 0xFFFFFF) or (background or 0x0))
		table.insert(picture, random and math.random(0x0, 0xFFFFFF) or (foreground or 0x0))
		table.insert(picture, alpha or 0x0)
		table.insert(picture, random and string.char(math.random(65, 90)) or (symbol or " "))
	end

	return picture
end

function image.copy(picture)
	local newPicture = {}
	
	for i = 1, #picture do
		newPicture[i] = picture[i]
	end

	return newPicture
end

function image.save(path, picture, encodingMethod)
	encodingMethod = encodingMethod or 6
	
	local file, reason = Filesystem.open(path, "wb")
	
	if file then	
		if encodingMethodsWrite[encodingMethod] then
			file:write(OCIFSignature, string.char(encodingMethod))

			local result, reason = xpcall(encodingMethodsWrite[encodingMethod], debug.traceback, file, picture)
			
			file:close()

			if result then
				return true
			else
				return false, "Failed to save OCIF image: " .. tostring(reason)
			end
		else
			file:close()
			
			return false, "Failed to save OCIF image: encoding method \"" .. tostring(encodingMethod) .. "\" is not supported"
		end
	else
		return false, "Failed to open file for writing: " .. tostring(reason)
	end
end

function image.readMetadata(file)
	local signature = file:readString(#OCIFSignature)
		
	if signature == OCIFSignature then
		local encodingMethod = file:readBytes(1)
		
		if encodingMethodsReadMetadata[encodingMethod] then
			local result, widthOrReason, height = xpcall(encodingMethodsReadMetadata[encodingMethod], debug.traceback, file, picture)
			
			if result then
				return signature, encodingMethod, widthOrReason, height
			else
				file:close()

				return false, "Failed to read OCIF metadata: " .. tostring(widthOrReason)
			end
		else
			file:close()
		
			return false, "Failed to read OCIF metadata: encoding method \"" .. tostring(encodingMethod) .. "\" is not supported"
		end
	else
		file:close()
		
		return false, "Failed to read OCIF metadata: binary signature \"" .. tostring(signature) .. "\" is not valid"
	end
end

function image.readPixelData(file, encodingMethod, width, height)
	local picture = {}
	local result, reason = xpcall(encodingMethodsRead[encodingMethod], debug.traceback, file, picture, width, height)
	file:close()

	if result then
		return picture
	else
		return false, "Failed to read OCIF pixel data: " .. tostring(reason)
	end
end

function image.load(path)
	local file, reason = Filesystem.open(path, "rb")
	
	if file then
		local signature, encodingMethod, width, height = image.readMetadata(file)

		if signature then
			return image.readPixelData(file, encodingMethod, width, height)
		else
			return false, encodingMethod
		end
	else
		return false, "Failed to open file \"" .. tostring(path) .. "\" for reading: " .. tostring(reason)
	end
end

-------------------------------------------------------------------------------

function image.toString(picture)
	local charArray = {
		string.format("%02X", picture[1]),
		string.format("%02X", picture[2])
	}
	
	for i = 3, #picture, 4 do
		table.insert(charArray, string.format("%02X", color.to8Bit(picture[i])))
		table.insert(charArray, string.format("%02X", color.to8Bit(picture[i + 1])))
		table.insert(charArray, string.format("%02X", math.floor(picture[i + 2] * 255)))
		table.insert(charArray, picture[i + 3])

		if i % 603 == 0 then
			computer.pullSignal(0)
		end
	end

	return table.concat(charArray)
end

function image.fromString(pictureString)
	local picture = {
		tonumber("0x" .. unicode.sub(pictureString, 1, 2)),
		tonumber("0x" .. unicode.sub(pictureString, 3, 4)),
	}

	for i = 5, unicode.len(pictureString), 7 do
		table.insert(picture, color.to24Bit(tonumber("0x" .. unicode.sub(pictureString, i, i + 1))))
		table.insert(picture, color.to24Bit(tonumber("0x" .. unicode.sub(pictureString, i + 2, i + 3))))
		table.insert(picture, tonumber("0x" .. unicode.sub(pictureString, i + 4, i + 5)) / 255)
		table.insert(picture, unicode.sub(pictureString, i + 6, i + 6))
	end

	return picture
end

--------------------------------------------------------------------------------

function image.set(picture, x, y, background, foreground, alpha, symbol)
	local index = image.getIndex(x, y, picture[1])
	picture[index], picture[index + 1], picture[index + 2], picture[index + 3] = background, foreground, alpha, symbol

	return picture
end

function image.get(picture, x, y)
	local index = image.getIndex(x, y, picture[1])
	return picture[index], picture[index + 1], picture[index + 2], picture[index + 3]
end

function image.getSize(picture)
	return picture[1], picture[2]
end

function image.getWidth(picture)
	return picture[1]
end

function image.getHeight(picture)
	return picture[2]
end

function image.transform(picture, newWidth, newHeight)
	local newPicture, stepWidth, stepHeight, background, foreground, alpha, symbol = {newWidth, newHeight}, picture[1] / newWidth, picture[2] / newHeight
	
	local x, y = 1, 1
	for j = 1, newHeight do
		for i = 1, newWidth do
			background, foreground, alpha, symbol = image.get(picture, math.floor(x), math.floor(y))
			table.insert(newPicture, background)
			table.insert(newPicture, foreground)
			table.insert(newPicture, alpha)
			table.insert(newPicture, symbol)

			x = x + stepWidth
		end

		x, y = 1, y + stepHeight
	end

	return newPicture
end

function image.crop(picture, fromX, fromY, width, height)
	if fromX >= 1 and fromY >= 1 and fromX + width - 1 <= picture[1] and fromY + height - 1 <= picture[2] then
		local newPicture, background, foreground, alpha, symbol = {width, height}
		
		for y = fromY, fromY + height - 1 do
			for x = fromX, fromX + width - 1 do
				background, foreground, alpha, symbol = image.get(picture, x, y)
				table.insert(newPicture, background)
				table.insert(newPicture, foreground)
				table.insert(newPicture, alpha)
				table.insert(newPicture, symbol)
			end
		end

		return newPicture
	else
		return false, "Failed to crop image: target coordinates are out of source range"
	end
end

function image.flipHorizontally(picture)
	local newPicture, background, foreground, alpha, symbol = {picture[1], picture[2]}
	
	for y = 1, picture[2] do
		for x = picture[1], 1, -1 do
			background, foreground, alpha, symbol = image.get(picture, x, y)
			table.insert(newPicture, background)
			table.insert(newPicture, foreground)
			table.insert(newPicture, alpha)
			table.insert(newPicture, symbol)
		end
	end

	return newPicture
end

function image.flipVertically(picture)
	local newPicture, background, foreground, alpha, symbol = {picture[1], picture[2]}
	
	for y = picture[2], 1, -1 do
		for x = 1, picture[1] do
			background, foreground, alpha, symbol = image.get(picture, x, y)
			table.insert(newPicture, background)
			table.insert(newPicture, foreground)
			table.insert(newPicture, alpha)
			table.insert(newPicture, symbol)
		end
	end

	return newPicture
end

function image.expand(picture, fromTop, fromBottom, fromLeft, fromRight, background, foreground, alpha, symbol)
	local newPicture = image.create(picture[1] + fromRight + fromLeft, picture[2] + fromTop + fromBottom, background, foreground, alpha, symbol)

	for y = 1, picture[2] do
		for x = 1, picture[1] do
			image.set(newPicture, x + fromLeft, y + fromTop, image.get(picture, x, y))
		end
	end

	return newPicture
end

function image.blend(picture, blendColor, transparency)
	local newPicture = {picture[1], picture[2]}

	for i = 3, #picture, 4 do
		table.insert(newPicture, color.blend(picture[i], blendColor, transparency))
		table.insert(newPicture, color.blend(picture[i + 1], blendColor, transparency))
		table.insert(newPicture, picture[i + 2])
		table.insert(newPicture, picture[i + 3])
	end

	return newPicture
end

function image.rotate(picture, angle)
	local function copyPixel(newPic, oldPic, index)
		table.insert(newPic, oldPic[index])
		table.insert(newPic, oldPic[index + 1])
		table.insert(newPic, oldPic[index + 2])
		table.insert(newPic, oldPic[index + 3])
	end

	if angle == 90 then
		local newPicture = {picture[2], picture[1]}
		
		for i = 1, picture[2] do
			for j = picture[1], 1, -1 do
				copyPixel(newPicture, picture, image.getIndex(i, j, picture[2]))
			end
		end

		return newPicture
	elseif angle == 180 then
		local newPicture = {picture[1], picture[2]}
		
		for j = picture[1], 1, -1 do
			for i = picture[2], 1, -1 do
				copyPixel(newPicture, picture, image.getIndex(i, j, picture[2]))
			end
		end

		return newPicture
	elseif angle == 270 then
		local newPicture = {picture[2], picture[1]}
		
		for i = picture[2], 1, -1 do
			for j = 1, picture[1] do
				copyPixel(newPicture, picture, image.getIndex(i, j, picture[2]))
			end
		end

		return newPicture
	else
		error("Can't rotate image: angle must be 90, 180 or 270 degrees.")
	end
end

function image.hueSaturationBrightness(picture, hue, saturation, brightness)
	local function calculate(c)
		local h, s, b = color.integerToHSB(c)
		
		b = b + brightness; if b < 0 then b = 0 elseif b > 1 then b = 1 end
		s = s + saturation; if s < 0 then s = 0 elseif s > 1 then s = 1 end
		h = h + hue; if h < 0 then h = 0 elseif h > 360 then h = 360 end

		return color.HSBToInteger(h, s, b)
	end

	for i = 3, #picture, 4 do
		picture[i] = calculate(picture[i])
		picture[i + 1] = calculate(picture[i + 1])
	end

	return picture
end

function image.hue(picture, hue)
	return image.hueSaturationBrightness(picture, hue, 0, 0)
end

function image.saturation(picture, saturation)
	return image.hueSaturationBrightness(picture, 0, saturation, 0)
end

function image.brightness(picture, brightness)
	return image.hueSaturationBrightness(picture, 0, 0, brightness)
end

function image.blackAndWhite(picture)
	return image.hueSaturationBrightness(picture, 0, -1, 0)
end

function image.colorBalance(picture, r, g, b)
	local function calculate(c)
		local rr, gg, bb = color.integerToRGB(c)

		rr = rr + r
		gg = gg + g
		bb = bb + b
		
		if rr < 0 then rr = 0 elseif rr > 255 then rr = 255 end
		if gg < 0 then gg = 0 elseif gg > 255 then gg = 255 end
		if bb < 0 then bb = 0 elseif bb > 255 then bb = 255 end

		return color.RGBToInteger(rr, gg, bb)
	end

	for i = 3, #picture, 4 do
		picture[i] = calculate(picture[i])
		picture[i + 1] = calculate(picture[i + 1])
	end

	return picture
end

function image.invert(picture)
	for i = 3, #picture, 4 do
		picture[i] = 0xffffff - picture[i]
		picture[i + 1] = 0xffffff - picture[i + 1]
	end

	return picture 
end

function image.getGaussianBlurKernel(radius, weight)
	local size, index, sum, weightSquared2, value =
		radius * 2 + 1,
		2,
		0,
		2 * weight * weight

	local kernel, constant =
		{size},
		1 / (math.pi * weightSquared2)
	
	-- Filling convolution matrix
	for y = -radius, radius do
		for x = -radius, radius do
			value = constant * math.exp(-((y * y) + (x * x)) / weightSquared2);
			kernel[index] = value
			sum = sum + value;

			index = index + 1
		end
	end

	index = 2

	for y = 1, size do
		for x = 1, size do
			kernel[index] = kernel[index] * 1 / sum;

			index = index + 1
		end
	end

    return kernel;
end

function image.convolve(picture, kernel)
	-- Processing
	local
		pictureWidth,
		pictureHeight,
		kernelSize,
		pictureIndex,
		kernelIndex,
		kernelValue,
		rAcc,
		gAcc,
		bAcc,
		r,
		g,
		b,
		x,
		y = picture[1], picture[2], kernel[1], 3

	local newPicture, kernelRadius =
		{ pictureWidth, pictureHeight },
		math.floor(kernelSize / 2)

	for pictureY = 1, pictureHeight do
		for pictureX = 1, pictureWidth do
			rAcc, gAcc, bAcc, kernelIndex = 0, 0, 0, 2

			-- Summing
			for kernelY = -kernelRadius, kernelRadius do
				y = pictureY + kernelY

				if y >= 1 and y <= pictureHeight then
					for kernelX = -kernelRadius, kernelRadius do
						x = pictureX + kernelX

						if x >= 1 and x <= pictureWidth then
							kernelValue = kernel[kernelIndex]
							r, g, b = color.integerToRGB(picture[4 * (pictureWidth * (y - 1) + x) - 1])

							rAcc, gAcc, bAcc = rAcc + r * kernelValue, gAcc + g * kernelValue, bAcc + b * kernelValue
						end

						kernelIndex = kernelIndex + 1
					end
				else
					kernelIndex = kernelIndex + kernelSize
				end
			end

			-- Setting pixel values on new picture
			if rAcc > 255 then
				rAcc =  255
			elseif rAcc < 0 then
				rAcc = 0
			else
				rAcc = rAcc - rAcc % 1
			end

			if gAcc > 255 then
				gAcc =  255
			elseif gAcc < 0 then
				gAcc = 0
			else
				gAcc = gAcc - gAcc % 1
			end

			if bAcc > 255 then
				bAcc =  255
			elseif bAcc < 0 then
				bAcc = 0
			else
				bAcc = bAcc - bAcc % 1
			end

			newPicture[pictureIndex] = color.RGBToInteger(rAcc, gAcc, bAcc)
			pictureIndex = pictureIndex + 1

			newPicture[pictureIndex] = 0x0
			pictureIndex = pictureIndex + 1

			newPicture[pictureIndex] = picture[pictureIndex]
			pictureIndex = pictureIndex + 1

			newPicture[pictureIndex] = " "
			pictureIndex = pictureIndex + 1
		end
	end

	return newPicture
end

--------------------------------------------------------------------------------

return image
