--[[
Cellent
A Toolbox spreadsheet application by trollbreeder
Original program: Speadersheet by Konlab
License: Do whatever the fuck you want with it

Other credits:
Creator: Idea
Dog: Tostring method to hide .0 on the ends of numbers
]]


local letters = {"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z" }
local lettersz = {[0] = "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z" }

local lettermap = {}
for i=1,#letters do
	lettermap[letters[i]] = i
end
local function isFunc(input)
	return input:sub(1,1) == "="
end
local function letterToNumber(str)
    local aVal = string.byte("A")-1
    local sVal = 0
    if #str == 2 then
		sVal = (26*(string.byte(str:sub(1,1))-aVal))+(string.byte(str:sub(2,2))-aVal)
    elseif #str == 0 then
		sVal = 0
	else
		sVal = string.byte(str)-aVal
    end
	
    return sVal
end
local function numberToLetter(number)
    number=number-1
    local a = math.floor(number % 26)
    local b = math.floor(number / 26)-1
    if number < 26 then 
        b = ""
    else
        b = (lettersz[b]):upper()
    end
    
    a = (lettersz[a]):upper()
    
    out = b..a
    return out
end
local function myRead(oldtxt)
	oldtxt = oldtxt or ""
	term.setCursorBlink(true)
	local pos = #oldtxt+1
	local x,y = term.getCursorPos()
	local event, key, param2, param3
	local keep = true
	local handler = {
		key = {
			[keys.left] = function()
				pos = pos > 1 and pos - 1 or pos
			end;
			[keys.right] = function()
				pos = pos <= #oldtxt and pos + 1 or pos
			end;
			[keys.backspace] = function()
				if pos > 1 then
					oldtxt = oldtxt:sub(1,pos-2) .. oldtxt:sub(pos, #oldtxt)
					pos = pos - 1
				end
			end;
			[keys.delete] = function()
				if pos <= #oldtxt then
					oldtxt = oldtxt:sub(1,pos-1) .. oldtxt:sub(pos+1, #oldtxt)
				end
			end;
			[keys.enter] = function()
				keep = false
			end;
		};
		char = function()
			oldtxt = oldtxt:sub(1,pos-1) .. key .. oldtxt:sub(pos, #oldtxt)
			pos = pos + #key
		end;
		paste = function()
			oldtxt = oldtxt:sub(1,pos-1) .. key .. oldtxt:sub(pos, #oldtxt)
			pos = pos + #key
		end;
	}
	handler.paste = handler.char
	while keep do
		term.setCursorPos(x,y)
		term.clearLine()
		term.write(oldtxt)
		term.setCursorPos(x - 1 + pos, y)
		event, key, param2, param3 = os.pullEvent()
		if handler[event] then
			if type(handler[event]) == "table" and handler[event][key] then
				handler[event][key]()
			elseif type(handler[event]) == "function" then
				handler[event]()
			end
		end
	end
	term.setCursorBlink(false)
	return oldtxt
end
--Setup vars:
	--general vars
	local filepath = ""
	--display vars
	local scroll = {0,0}
	local showncolumncount = 10
	local w,h = term.getSize()
	local gridw, gridh = w-2, h-2 --why not put this here :P
	local gridx, gridy = 4,2
	local defaultColumnWidth = 10
	--selection vars
	local selected = {1,1} --selected cell position
	local endselection = {}
	local multiselect = false
	--file content vars
	local grid = {}
	local columnWidths = {}
	--copy/paste vars
	local clipboard = {}
	--function vars
	local precalculated = {}
	--theme vars
	local theme = term.isColor() and {
		default = {colors.white, colors.black},
		selected = {colors.green, colors.white},
		multiselected = {colors.lime, colors.black},
		menu = {colors.green, colors.white},
		border = {colors.lightGray, colors.black}
	} or {
		default = {colors.black, colors.white},
		selected = {colors.white, colors.black},
		multiselected = {colors.gray, colors.black},
		menu = {colors.white, colors.black},
		border = {colors.lightGray, colors.black}
	}
--Handle args:
local tArgs = {...}
if #tArgs < 1 then --not enough args
	--[[print("Please specify filepath (file extension is .tms)")
	tArgs[1] = tostring(read())]]
	--[[if tFilepath == "" then
        while true do
            local i = {lUtils.inputbox("Filepath","Please enter a new filepath:",29,10,{"Done","Cancel"})}
            if i[2] == false or i[4] == "Cancel" then
                return false
            end
            if fs.exists(i[1]) == true then
                lUtils.popup("Error","This path already exists!",29,9,{"OK"})
            else
                tFilepath = i[1]
                break
            end
        end
    end]]
end
filepath = tArgs[1]
--Formula Functions
--[[
	Cellent formula functions + formatting
]]
local function mytonumber(input)
	return tonumber(input) or 0
end

local env = {
	add = function(...)
		local output = arg[1]
		for k, v in ipairs(arg) do
			if k ~= 1 then output = output + mytonumber(v) end
		end
		return output
	end;
	sub = function(...)
		local output = arg[1]
		for k, v in ipairs(arg) do
			if k ~= 1 then output = output - mytonumber(v) end
		end
		return output
	end;
	mul = function(...)
		local output = arg[1]
		for k, v in ipairs(arg) do
			if k ~= 1 then output = output * mytonumber(v) end
		end
		return output
	end;
	div = function(...)
		local output = arg[1]
		for k, v in ipairs(arg) do
			if k ~= 1 then output = output / mytonumber(v) end
		end
		return output
	end;
	pow = function(...)
		local output = arg[1]
		for k, v in ipairs(arg) do
			if k ~= 1 then output = output ^ mytonumber(v) end
		end
		return output
	end;
	count = function(...)
		return #arg
	end;
	avg = function(...)
		local output = arg[1]
		for k, v in ipairs(arg) do
			if k ~= 1 then output = output + mytonumber(v) end
		end
		output = output / #arg
		return output
	end;
	min = function(...)
		local output = arg[1]
		for k, v in ipairs(arg) do
			if k ~= 1 then output = math.min(output, mytonumber(v)) end
		end
		return output
	end;
	max = function(...)
		local output = arg[1]
		for k, v in ipairs(arg) do
			if k ~= 1 then output = math.max(output, mytonumber(v)) end
		end
		return output
	end;
}
-- todo: custom formulas?

local function split(self, sep)
	if sep == nil then
		sep = "%s"
	end
	local t={} ; i=1
	for str in string.gmatch(self, "([^"..sep.."]+)") do
		t[i] = str
		i = i + 1
	end
	return t
end

function parseFunc(input) --converts "=Funcname lettercodnumbercode" in {"A1", "B2"} format
	local cells = {}
	input = input:sub(2,#input)
	-- table with splitted input
	local tokens = split(input)
	--error(split(input)[2])
	for i=1, #tokens do
		local token = tokens[i]
		local letter = {""}
		local number = {""}
		local range = false
		local comma = false
		for c in token:gmatch("[%u%d:;]") do
			--iterate over characters
			if c == ":" then
				-- Todo: Make ranges not eldritch.
				-- I know, manually putting in everything is pain
				-- but this is even more pain
				--range = true
			elseif tonumber(c) then
				if range or comma then
					number[#number+1] = range and ":" or comma and ";"
					number[#number+1] = c
					range = false
					comma = false
				else
					number[#number] = number[#number] .. c
				end
			elseif lettermap[c:lower()] then
				if range or comma then
					letter[#letter+1] = range and ":" or comma and ";"
					letter[#letter+1] = c
					range = false
					comma = false
				else
					letter[#letter] = letter[#letter] .. c
				end
			end
		end
		local range, l = false
		local nrange = false
		local function doNumbers()
			for j=1,#number do
				local n = number[j]
				if n == "," then
					--ignore
					nrange = false
				elseif n == ":" then
					nrange = true
				elseif nrange then
					for num = number[j-2] + 1, n do
						cells[#cells+1] = l .. num
					end
					nrange = false
				else
					cells[#cells+1] = l .. n
					nrange = false
				end
			end
		end
		for k=1,#letter do
			l = letter[k]
			if l == ";" then
				--ignore
				range = false
			elseif l == ":" then
				range = true
			elseif range then
				local constl = l
				for m = letterToNumber(letter[k-2]) + 1, letterToNumber(constl) do
					l = numberToLetter(m)
					doNumbers()
				end
				range = false
			else
				range = false
				doNumbers()
			end
		end
	end
	return cells
end
local function convertParsedToIndexes(parsed) --converts the output of parseFunc to {{1,1},{2,2}} format
	local token, letter, number
	local output = {}
	for i=1,#parsed do
		token = parsed[i]
		letter, number = "",""
		for c in token:gmatch("[%u%d]") do
			if tonumber(c) then
				number = number .. c
			else
				letter = letter .. c
			end
		end
		output[i] = {letterToNumber(letter), tonumber(number)}
	end
	return output
end

-- extracts the value from a table of indexes, then puts the values in a table
local function extractValue(indexes)
	-- Table to output
	local output = {}
	for k in ipairs(indexes) do
		-- Coordinates to the cell
		local cx,cy = indexes[k][1], indexes[k][2]
		-- Next cell was already calculated.
		-- Get the precalculated value (optimization)
		if precalculated[cx] and precalculated[cx][cy] then
			output[k] = precalculated[cx][cy]
		else
			-- Get the value of the cell
			output[k] = grid[cx] and grid[cx][cy] and grid[cx][cy] or ""
		end
	end
	return output
end

-- Calculates formula using string
local function doFunction(input)
	-- gets a table like {"A1", "A2", "C7"}
	local parse = parseFunc(input)
	-- gets a table like {"500", "200", "fdhgjlk"}
	local value = extractValue(convertParsedToIndexes(parse))
	-- remove equal sign
	input = string.gsub(input, "=", "")
	-- substitute cell addresses by their values
	for k = 1, #parse do
		if value[k] ~= "" or parse[k] ~= "" then input = string.gsub(input, parse[k], value[k]) end
	end
	-- load math
	local f = load("return "..input, nil, nil, env)
	
	if f then
		local ok, res = pcall(f)
		if ok then
			output = res
		else
			output = "ERROR?"
		end
	else
		output = "ERROR!"
	end
	return output
end

local function formatText(text, cwidth, x, y)
	if precalculated[x] and precalculated[x][y] then
		text = precalculated[x][y]
	end
	while isFunc(text) or text:sub(1,1) == "{" do
		text = tostring(doFunction(text))
		if not isFunc(text) and x and y then
			if not precalculated[x] then
				precalculated[x] = {}
			end
			precalculated[x][y] = text
		end
	end
	if #text >= cwidth then
		return text:sub(1,cwidth)
	elseif tonumber(text) then
		return string.rep(" ",(cwidth) - #text) .. text
	else 
		return text .. string.rep(" ",(cwidth) - #text)
	end
end
--[[
	Other functions
]]
local function selectColors(name) --selects colors, args: name of theme
	local cols = theme[name]
	term.setBackgroundColor(cols[1])
	term.setTextColor(cols[2])
end
local function redrawGrid() --redraws grid  and bottom line
	--this is the function that will need lots of optimization probably
	local drawing = {scroll[1], scroll[2]} --which cell are we drawing (to get the content)
	for y=gridy, gridh + gridy - 1 do
		drawing[2] = drawing[2] + 1
		drawing[1] = scroll[1]
		term.setCursorPos(gridx, y)
		for i=1,showncolumncount do
			drawing[1] = drawing[1] + 1
			local cwidth = columnWidths[drawing[1]] or defaultColumnWidth
			selectColors((selected[1] == drawing[1] and selected[2] == drawing[2]) and "selected" or (multiselect and (selected[1] >= drawing[1] and selected[2] >= drawing[2]) and (endselection[1] <= drawing[1] and endselection[2] <= drawing[2])) and "multiselected" or "default")
			local realtext = grid[drawing[1]] and grid[drawing[1]][drawing[2]] and grid[drawing[1]][drawing[2]] or ""
			local text = formatText(realtext, cwidth - 1, drawing[1], drawing[2])
			term.write(text)
			if selected[1] == drawing[1] and selected[2] == drawing[2] then
				local oldx,oldy = term.getCursorPos()
				term.setCursorPos(1,h)
				selectColors("menu")
				--This is repeated because of functions later
				term.write(realtext .. (#realtext < w and string.rep(" ",w - #realtext) or ""))
				term.setCursorPos(oldx,oldy)
			end
			selectColors("border")
			term.write(" ")
		end
	end
end
local function redrawMenu() --redraws column and row numbers
	--redraw top column numbers
	do
		local drawing = scroll[1]
		term.setCursorPos(gridx, 1)
		selectColors("menu")
		term.clearLine()
		term.setCursorPos(gridx, 1)
		for i=1,showncolumncount do
			drawing = drawing + 1
			local cwidth = columnWidths[drawing] or defaultColumnWidth
			term.write(numberToLetter(drawing))
			term.write(string.rep(" ",cwidth - #numberToLetter(drawing)))
		end
	end
	--redraw line numbers
	local drawing = scroll[2]
	for y=gridy, gridh + gridy - 1 do
		drawing = drawing + 1
		term.setCursorPos(1, y)
		term.write(tostring(drawing) .. string.rep(" ",3 - #tostring(drawing)))
	end
end
local function checkScroll() --scrolls if needed
	local scrolled = false
	--horizontal:
	local cx = gridx
	local count = 0
	local drawing = scroll[1]
	for i=1,w do -- count how many columns are on screen
		drawing = drawing + 1
		local cwidth = columnWidths[drawing] or defaultColumnWidth
		cx = cx + cwidth
		count = count + 1
		if cx >= w then break end
	end
	showncolumncount = count
	while selected[1] - scroll[1] < 1 do
		scroll[1] = scroll[1] - 1
		scrolled = true
	end
	while selected[1] > scroll[1] + count do
		scroll[1] = scroll[1] + 1
		scrolled = true
	end
	--vertical:
	while selected[2] - scroll[2] < 1 do
		scroll[2] = scroll[2] - 1
		scrolled = true
	end
	while selected[2] > scroll[2] + gridh do
		scroll[2] = scroll[2] + 1
		scrolled = true
	end
	if scrolled then
		redrawGrid()
		redrawMenu()
	end
end
local function save() --saves persistant data
	os.sleep(0)
	if not (filepath and fs.exists(filepath)) then
        while true do
            local i = {lUtils.inputbox("Cellent","Please enter a new filepath (ending in .tms):",29,10,{"Done","Cancel"})}
            if i[2] == false or i[4] == "Cancel" then
                redrawGrid()
				redrawMenu()
				return false
            end
            if fs.exists(i[1]) == true then
                lUtils.popup("Error","This path already exists!",29,9,{"OK"})
            else
                filepath = i[1]
				redrawGrid()
				redrawMenu()
                break
            end
        end
	end
	
	local h = fs.open(filepath,"w")
	local cont = textutils.serialize{
		grid,
		selected,
		endselection,
		multiselect,
		columnWidths,
		"placeholder",
		scroll
	}
	h.write(cont)
	h.close()
end
local function reload() --loads persistant data
	if filepath and fs.exists(filepath)then
		local h = fs.open(filepath,"r")
		local cont = textutils.unserialize(h.readAll())
		h.close()
		grid = cont[1] or {}
		selected = cont[2] or {1,1}
		endselection = cont[3] or {1,1}
		multiselect = cont[4]
		columnWidths = cont[5] or {}
		scroll = cont[7] or {0,0}
	end
end
local function cancel() --cancels multiselection
	if multiselect then
		multiselect = false
	end
	endselection = {selected[1], selected[2]}
end
local function initMultiselect() --inits multiselection if needed
	if not multiselect then
		multiselect = true
		endselection = {selected[1], selected[2]}
	end
end
local function convertSelection() -- converts multi/single selection to a smaller sub grid and returns it
	local subx, suby = 1,1
	local subgrid = {}
	for x = endselection[1], selected[1] do
		subgrid[subx] = {}
		for y = endselection[2], selected[2] do
			subgrid[subx][suby] = grid[x] and grid[x][y] and grid[x][y] or ""
			suby = suby + 1
		end
		suby = 1
		subx = subx + 1
	end
	return subgrid
end
local function pasteSubgrid(subgrid, x, y) --pastes subgrid in main grid
	for i=#subgrid, 1, -1 do
		local line = subgrid[i]
		for j = #line, 1, -1 do
			local cell = line[j]
			if not grid[x + i - 1] then
				grid[x + i - 1] = {}
			end
			grid[x + i - 1][y + j - 1] = cell
		end
	end
	precalculated = {}
end
local function deleteSelection()
	for x = endselection[1], selected[1] do
		for y = endselection[2], selected[2] do
			if grid[x] then
				grid[x][y] = ""
			end
		end
	end
	precalculated = {}
end
local function copyCell(bx,by,ex,ey) -- Copy cell over to a different one
	if not grid[ex] then
		grid[ex] = {}
	end
	local txt = grid[bx] and grid[bx][by] or ""
	grid[ex][ey] = txt
	precalculated = {}
end
reload()
--Key-event table:
local handler = {
	--[[
		movement:
		arrow keys = move
		p/l = multiselect (move right bottom corner right/down)
		enter = edit
		home = first column
		end = first row
		page up = page up
		page down = page down
		general:
		s = save
		q = exit
		copy/paste:
		c = copy
		v = paste
		x = cut
		n = make column less wide
		m = make column more wide
		u/h/j/k = copy one cell ^/</v/>
	]]
	key = {
		[keys.up] = function() cancel() selected[2] = selected[2] > 1 and selected[2] - 1 or 1 redrawGrid() end;
		[keys.down] = function() cancel() selected[2] = selected[2] + 1 redrawGrid() end;
		[keys.left] = function() cancel() selected[1] = selected[1] > 1 and selected[1] - 1 or 1 redrawGrid() end;
		[keys.right] = function() cancel() selected[1] = selected[1] + 1 redrawGrid() end;
		[keys.home] = function() cancel() selected[1] = 1 redrawGrid() end;
		[keys["end"]] = function() cancel() selected[2] = 1 redrawGrid() end;
		[keys.pageUp] = function() cancel() selected[2] = selected[2] > gridh-1 and selected[2] - (gridh-1) or 1 redrawGrid() end;
		[keys.pageDown] = function() cancel() selected[2] = selected[2] + gridh - 1 redrawGrid() end;
		[keys.p] = function() initMultiselect() selected[1] = selected[1] + 1 redrawGrid() end;
		[keys.l] = function() initMultiselect() selected[2] = selected[2] + 1 redrawGrid() end;
		[keys.enter] = function()
			term.setCursorPos(1,h)
			selectColors("menu")
			term.clearLine()
			term.setCursorPos(1,h)
			if not grid[selected[1]] then
				grid[selected[1]] = {}
			end
			grid[selected[1]][selected[2]] = myRead(grid[selected[1]][selected[2]] and grid[selected[1]][selected[2]] or "")
			precalculated = {}
			redrawGrid()
			redrawMenu()
		end;
		[keys.s] = function()
			save()
		end;
		[keys.q] = function()
			term.setBackgroundColor(colors.black)
			term.setTextColor(colors.white)
			term.clear()
			term.setCursorPos(1,1)
			sleep(0)
			error("Thanks for using Cellent!",0)
		end;
		[keys.c] = function()
			clipboard = convertSelection()
		end;
		[keys.v] = function()
			pasteSubgrid(clipboard, selected[1], selected[2])
			redrawGrid()
		end;
		[keys.x] = function()
			clipboard = convertSelection()
			deleteSelection()
			redrawGrid()
		end;
		[keys.n] = function()
			local neww = (columnWidths[selected[1]] or defaultColumnWidth) - 1
			columnWidths[selected[1]] = neww > 0 and neww or 1
			redrawGrid()
			redrawMenu()
		end;
		[keys.m] = function()
			local neww = (columnWidths[selected[1]] or defaultColumnWidth) + 1
			columnWidths[selected[1]] = neww < gridw and neww or gridw
			redrawGrid()
			redrawMenu()
		end;
		[keys.backspace] = function() grid[selected[1]][selected[2]] = ""; precalculated = {}; redrawGrid() end;
		[keys.delete] = function() grid[selected[1]][selected[2]] = ""; precalculated = {}; redrawGrid() end;
	},
	mouse_click = {
		-- Left click 
		-- param2: x
		-- param3: y
		[1] = function() 
			cancel()
			selected[1] = math.max(1, math.floor(param2/(columnWidths[selected[1]] or defaultColumnWidth) + scroll[1]+0.7))
			selected[2] = math.max(1, param3 + scroll[2]-1)
			redrawGrid()
		end;
	},
	mouse_scroll = {
		[-1] = function() scroll[2] = math.max(0, scroll[2] - 1); redrawGrid(); redrawMenu() end;
		[1] = function() scroll[2] = math.max(0, scroll[2] + 1); redrawGrid(); redrawMenu() end;
	};
}
handler.key[keys.u] = function()
	if selected[2] == 1 then return end
	copyCell(selected[1], selected[2], selected[1], selected[2] - 1)
	handler["key"][keys.up]()
end;
handler.key[keys.h] = function()
	if selected[1] == 1 then return end
	copyCell(selected[1], selected[2], selected[1] - 1, selected[2])
	handler["key"][keys.left]()
end;
handler.key[keys.j] = function()
	copyCell(selected[1], selected[2], selected[1], selected[2] + 1)
	handler["key"][keys.down]()
end;
handler.key[keys.k] = function()
	copyCell(selected[1], selected[2], selected[1] + 1, selected[2])
	handler["key"][keys.right]()
end;
--Main loop:
term.clear()
redrawGrid()
redrawMenu()
while true do
	event, param, param2, param3 = os.pullEvent()
	if handler[event] and handler[event][param] then
		handler[event][param]()
		checkScroll()
	end
	
	if event == "term_resize" then
		w,h = term.getSize()
		gridw, gridh = w-2, h-2
		redrawGrid()
		redrawMenu()
		checkScroll()
	end
	--[[local scroll = {0,0}
	local showncolumncount = 10
	local w,h = term.getSize()
	local gridw, gridh = w-2, h-2 --why not put this here :P
	local gridx, gridy = 4,2
	local defaultColumnWidth = 10]]
end