local parentShell = _ENV.shell
_ENV.shell = { }

local trace = require('opus.trace')
local Util  = require('opus.util')

local fs       = _G.fs
local settings = _G.settings
local shell    = _ENV.shell

local DIR = (parentShell and parentShell.dir()) or ""
local PATH = (parentShell and parentShell.path()) or ".:/rom/programs"
local tAliases = (parentShell and parentShell.aliases()) or {}
local tCompletionInfo = (parentShell and parentShell.getCompletionInfo()) or {}

local bExit = false
local tProgramStack = {}

local function tokenise(...)
	local sLine = table.concat({ ... }, ' ')
	local tWords = { }
	local bQuoted = false
	for match in string.gmatch(sLine .. "\"", "(.-)\"") do
		if bQuoted then
			table.insert(tWords, match)
		else
			for m in string.gmatch(match, "[^ \t]+") do
				table.insert(tWords, m)
			end
		end
		bQuoted = not bQuoted
	end

	return tWords
end

local defaultHandlers = {
	function(env, command, args)
		return command:match("^(https?:)") and {
			title = fs.getName(command),
			path  = command,
			args  = args,
			load  = Util.loadUrl,
			env   = env,
		}
	end,

	function(env, command, args)
		command = env.shell.resolveProgram(command)
			or error('No such program')

		_G.requireInjector(env, fs.getDir(command))
		return {
			title = fs.getName(command):match('([^%.]+)'),
			path  = command,
			args  = args,
			load  = loadfile,
			env   = env,
		}
	end,
}

function shell.getHandlers()
	if parentShell and parentShell.getHandlers then
		return parentShell.getHandlers()
	end
	return defaultHandlers
end

local handlers = shell.getHandlers()

function shell.registerHandler(fn)
	table.insert(handlers, 1, fn)
end

local function handleCommand(env, command, args)
	for _,v in pairs(handlers) do
		local pi = v(env, command, args)
		if pi then
			return pi
		end
	end
end

local function run(...)
	local args = tokenise(...)
	if #args == 0 then
		error('No such program')
	end

	local pi = handleCommand(shell.makeEnv(_ENV), table.remove(args, 1), args)

	local O_v_O, err = pi.load(pi.path, pi.env)
	if not O_v_O then
		error(err, -1)
	end

	if _ENV.multishell then
		_ENV.multishell.setTitle(_ENV.multishell.getCurrent(), pi.title)
	end

	tProgramStack[#tProgramStack + 1] = pi

	pi.env[ "arg" ] = { [0] = pi.path, table.unpack(pi.args) }
	local r = { O_v_O(table.unpack(pi.args)) }

	tProgramStack[#tProgramStack] = nil

	return table.unpack(r)
end

-- Install shell API
function shell.run(...)
	local oldTitle

	if _ENV.multishell then
		oldTitle = _ENV.multishell.getTitle(_ENV.multishell.getCurrent())
	end

	local r = { trace(run, ...) }

	if _ENV.multishell then
		_ENV.multishell.setTitle(_ENV.multishell.getCurrent(), oldTitle or 'shell')
	end

	return table.unpack(r)
end

function shell.exit()
	bExit = true
end

function shell.dir() return DIR end
function shell.setDir(d)
	d = fs.combine(d, '')
	if not fs.isDir(d) then
		error("Not a directory", 2)
	end
	DIR = d
end

function shell.path() return PATH end
function shell.setPath(p) PATH = p end

function shell.resolve( _sPath )
	local sStartChar = string.sub( _sPath, 1, 1 )
	if sStartChar == "/" or sStartChar == "\\" then
		return fs.combine( "", _sPath )
	else
		return fs.combine(DIR, _sPath )
	end
end

function shell.resolveProgram(_sCommand)
	if tAliases[_sCommand] ~= nil then
		_sCommand = tAliases[_sCommand]
	end

	local function check(f)
		return fs.exists(f) and not fs.isDir(f) and f
	end

	local function inPath()
		-- Otherwise, look on the path variable
		for sPath in string.gmatch(PATH or '', "[^:]+") do
			sPath = fs.combine(sPath, _sCommand )
			if check(sPath) then
				return sPath
			end
			if check(sPath .. '.lua') then
				return sPath .. '.lua'
			end
		end
	end

	-- so... even if you are in the rom directory and you run:
	-- 'packages/common/edit.lua', allow this even though it
	-- does not use a leading slash. Ideally, fs.combine would
	-- provide the leading slash... but it does not.
	return (not _sCommand:find('/')) and inPath()
                or check(shell.resolve(_sCommand))
		or check(shell.resolve(_sCommand) .. '.lua')
		or check(_sCommand)
		or check(_sCommand .. '.lua')
end

function shell.programs(_bIncludeHidden)
	local tItems = { }

	-- Add programs from the path
	for sPath in string.gmatch(PATH, "[^:]+") do
		sPath = shell.resolve(sPath)
		if fs.isDir( sPath ) then
			local tList = fs.list( sPath )
			for _,sFile in pairs( tList ) do
				if not fs.isDir( fs.combine( sPath, sFile ) ) and
					(_bIncludeHidden or string.sub( sFile, 1, 1 ) ~= ".") then
					tItems[ sFile ] = true
				end
			end
		end
	end

	-- Sort and return
	local tItemList = { }
	for sItem in pairs(tItems) do
		table.insert(tItemList, sItem)
	end
	table.sort(tItemList)
	return tItemList
end

function shell.completeProgram(sLine)
	if #sLine > 0 and string.sub(sLine, 1, 1) == '/' then
		-- Add programs from the root
		return fs.complete(sLine, '', true, false)
	end

	local tResults = { }
	local tSeen = { }

	-- Add aliases
	for sAlias in pairs( tAliases ) do
		if #sAlias > #sLine and string.sub(sAlias, 1, #sLine) == sLine then
			local sResult = string.sub(sAlias, #sLine + 1)
			if not tSeen[sResult] then
				table.insert(tResults, sResult .. ' ')
				tSeen[sResult] = true
			end
		end
	end

	-- Add programs from the path
	local tPrograms = shell.programs()
	for n=1,#tPrograms do
		local sProgram = tPrograms[n]
		if #sProgram >= #sLine and string.sub(sProgram, 1, #sLine) == sLine then
			local sResult = string.sub(sProgram, #sLine + 1)
			if not tSeen[sResult] then
				table.insert(tResults, sResult .. ' ')
				tSeen[sResult] = true
			end
		end
	end

	-- Sort and return
	table.sort(tResults)
	return tResults
end

function shell.complete(sLine)
	local tWords = tokenise(sLine)
	local nIndex = #tWords
	if string.sub(sLine, #sLine, #sLine) == ' ' and #Util.trim(sLine) > 0 then
		nIndex = nIndex + 1
	end

	if nIndex == 0 then
		return fs.complete('', shell.dir(), true, false)

	elseif nIndex == 1 then
		local results = shell.completeProgram(tWords[1] or '')
		for _, v in pairs(fs.complete(table.concat(tWords, ' '), shell.dir(), true, false)) do
			table.insert(results, v)
		end
		return results

	else
		local sPath = shell.resolveProgram(tWords[1])
		local sPart = tWords[nIndex] or ''
		local tPreviousParts = tWords
		tPreviousParts[nIndex] = nil
		local results
		local tInfo = tCompletionInfo[sPath]
		if tInfo then
			results = tInfo.fnComplete(shell, nIndex - 1, sPart, tPreviousParts)
		end
		return results and #results > 0 and results
			or fs.complete(sPart, shell.dir(), true, false)
	end
end

function shell.setCompletionFunction(sProgram, fnComplete)
	tCompletionInfo[sProgram] = { fnComplete = fnComplete }
end

function shell.getCompletionInfo()
	return tCompletionInfo
end

function shell.getRunningProgram()
	return tProgramStack[#tProgramStack] and tProgramStack[#tProgramStack].path
end

function shell.getRunningInfo()
	return tProgramStack[#tProgramStack]
end

-- convenience function for making a runnable env
function shell.makeEnv(env, dir)
	env = setmetatable(Util.shallowCopy(env), { __index = _G })
	_G.requireInjector(env, dir)
	return env
end

function shell.setAlias(_sCommand, _sProgram)
	tAliases[_sCommand] = _sProgram
end

function shell.clearAlias(_sCommand)
	tAliases[_sCommand] = nil
end

function shell.aliases()
	local tCopy = {}
	for sAlias, sCommand in pairs(tAliases) do
		tCopy[sAlias] = sCommand
	end
	return tCopy
end

function shell.newTab(tabInfo, ...)
	local args = tokenise(...)
	local path = table.remove(args, 1)
	path = shell.resolveProgram(path)

	if path then
		tabInfo.path = path
		tabInfo.args = args
		tabInfo.title = fs.getName(path):match('([^%.]+)')

		if path ~= 'sys/apps/shell.lua' then
			table.insert(tabInfo.args, 1, tabInfo.path)
			tabInfo.path = 'sys/apps/shell.lua'
		end
		return _ENV.multishell.openTab(_ENV, tabInfo)
	end
	return nil, 'No such program'
end

if not _ENV.multishell then
	function shell.newTab()
		error('Multishell is not available')
	end
end

function shell.openTab(...)
	return shell.newTab({ }, ...)
end

function shell.openForegroundTab( ... )
	return shell.newTab({ focused = true }, ...)
end

function shell.openHiddenTab( ... )
	return shell.newTab({ hidden = true }, ...)
end

function shell.switchTab(tabId)
	_ENV.multishell.setFocus(tabId)
end

local tArgs = { ... }
if #tArgs > 0 then
	return run(...)
end

local Config   = require('opus.config')
local Entry    = require('opus.entry')
local History  = require('opus.history')
local Input    = require('opus.input')
local Sound    = require('opus.sound')
local Terminal = require('opus.terminal')

local colors    = _G.colors
local os        = _G.os
local term      = _G.term
local textutils = _G.textutils

local oldTerm
local terminal  = term.current()
local _len      = string.len
local _rep      = string.rep
local _sub      = string.sub

local config = {
	color = {
		textColor = colors.white,
		commandTextColor = colors.yellow,
		directoryTextColor  = colors.orange,
		promptTextColor = colors.blue,
		directoryColor = colors.green,
		fileColor = colors.white,
		backgroundColor = colors.black,
	},
	displayDirectory = true,
}

Config.load('shellprompt', config)

local _colors = config.color
-- temp
if not _colors.backgroundColor then
  _colors.backgroundColor = colors.black
  _colors.fileColor = colors.white
end

if not terminal.scrollUp then
	terminal = Terminal.window(term.current())
	terminal.setMaxScroll(200)
	oldTerm = term.redirect(terminal)
	term.setBackgroundColor(_colors.backgroundColor)
	term.clear()
end

local palette = terminal.canvas.palette

local function autocomplete(line)
	local words = { }
	for word in line:gmatch("%S+") do
		table.insert(words, word)
	end
	if line:match(' $') then
		table.insert(words, '')
	end
	if #words == 0 then
		words = { '' }
	end

	local results = shell.complete(line) or { }

	Util.filterInplace(results, function(f)
		return not Util.key(results, f .. '/')
	end)
	local w = words[#words] or ''
	for k,arg in pairs(results) do
		results[k] = w .. arg
	end

	if #results == 1 then
		words[#words] = results[1]
		return table.concat(words, ' ')

	elseif #results > 1 then
		local function someComplete()
			-- ugly (complete as much as possible)
			local word = words[#words] or ''
			local i = #word + 1
			while true do
				local ch
				for _,f in ipairs(results) do
					if #f < i then
						words[#words] = _sub(f, 1, i - 1)
						return table.concat(words, ' ')
					end
					if not ch then
						ch = _sub(f, i, i)
					elseif _sub(f, i, i) ~= ch then
						if i == #word + 1 then
							return
						end
						words[#words] = _sub(f, 1, i - 1)
						return table.concat(words, ' ')
					end
				end
				i = i + 1
			end
		end

		local t = someComplete()
		if t then
			return t
		end

		print()

		local word = words[#words] or ''
		local prefix = word:match("(.*/)") or ''
		if #prefix > 0 then
			for _,f in ipairs(results) do
				if f:match("^" .. prefix) ~= prefix then
					prefix = ''
					break
				end
			end
		end

		local tDirs, tFiles = { }, { }
		for _,f in ipairs(results) do
			if fs.isDir(shell.resolve(f)) then
				f = f:gsub(prefix, '', 1)
				table.insert(tDirs, f)
			else
				f = f:gsub(prefix, '', 1)
				table.insert(tFiles, f)
			end
		end
		table.sort(tDirs)
		table.sort(tFiles)

		if #tDirs > 0 and #tDirs < #tFiles then
			local tw = term.getSize()
			local nMaxLen = tw / 8
			for _,sItem in pairs(results) do
				nMaxLen = math.max(_len(sItem) + 1, nMaxLen)
			end
			local nCols = math.floor(tw / nMaxLen)
			if #tDirs < nCols then
				for _ = #tDirs + 1, nCols do
					table.insert(tDirs, '')
				end
			end
		end

		if #tDirs > 0 then
			textutils.tabulate(_colors.directoryColor, tDirs, _colors.fileColor, tFiles)
		else
			textutils.tabulate(_colors.fileColor, tFiles)
		end

		term.setTextColour(_colors.promptTextColor)
		term.write("$ " )

		term.setTextColour(_colors.commandTextColor)
		return line
	end
end

local function shellRead(history)
	local lastLen = 0
	local entry = Entry({
		width = term.getSize() - 3,
		offset = 3,
	})

	history:reset()
	term.setCursorBlink(true)

	local function updateCursor()
		term.setCursorPos(3 + entry.pos - entry.scroll, select(2, term.getCursorPos()))
	end

	local function redraw()
		if terminal.scrollBottom then
			terminal.scrollBottom()
		end
		local _,cy = term.getCursorPos()
		term.setCursorPos(3, cy)
		entry.value = entry.value or ''
		local filler = #entry.value < lastLen
			and _rep(' ', lastLen - #entry.value)
			or ''
		local str = _sub(entry.value, entry.scroll + 1, entry.width + entry.scroll) .. filler
		local fg = _rep(palette[_colors.commandTextColor], #str)
		local bg = _rep(palette[_colors.backgroundColor], #str)
		if entry.mark.active then
			bg = _rep('f', entry.mark.x) ..
				_rep('7', entry.mark.ex - entry.mark.x) ..
				_rep('f', #entry.value - entry.mark.ex + #filler + 1)
			bg = _sub(bg, entry.scroll + 1, entry.scroll + #str)
		end
		term.blit(str, fg, bg)
		updateCursor()
		lastLen = #entry.value
	end

	while true do
		local event, p1, p2, p3 = os.pullEventRaw()

		local ie = Input:translate(event, p1, p2, p3)
		if ie then
			if ie.code == 'scroll_up' and terminal.scrollUp then
				terminal.scrollUp()

			elseif ie.code == 'scroll_down' and terminal.scrollDown then
				terminal.scrollDown()

			elseif ie.code == 'terminate' then
				bExit = true
				break

			elseif ie.code == 'enter' then
				break

			elseif ie.code == 'up'   or ie.code == 'control-p' or
						 ie.code == 'down' or ie.code == 'control-n' then
				entry:reset()
				if ie.code == 'up' or ie.code == 'control-p' then
					entry.value = history:back() or ''
				else
					entry.value = history:forward() or ''
				end
				entry.pos = #entry.value
				entry:updateScroll()
				redraw()

			elseif ie.code == 'tab' then
				entry.value = entry.value or ''
				if entry.pos == #entry.value then
					local cline = autocomplete(entry.value)
					if cline then
						entry.value = cline
						entry.pos = #entry.value
						entry:unmark()
						entry:updateScroll()
						redraw()
					else
						Sound.play('entity.villager.no')
					end
				end

			elseif ie.code == 'control-l' then
				term.clear()
				term.setCursorPos(1, 0) -- Y:0 ?
				break

			else
				entry:process(ie)
				entry.value = entry.value or ''
				if entry.textChanged then
					redraw()
				elseif entry.posChanged then
					updateCursor()
				end
			end

		elseif event == "term_resize" then
			terminal.reposition(1, 1, oldTerm.getSize())
			entry.width = term.getSize() - 3
			entry:updateScroll()
			redraw()
		end
	end

	print()
	term.setCursorBlink(false)
	return entry.value or ''
end

local history = History.load('usr/.shell_history', 100)

term.setBackgroundColor(_colors.backgroundColor)

if settings.get("motd.enable") then
	shell.run("motd")
end

while not bExit do
	if config.displayDirectory then
		term.setTextColour(_colors.directoryTextColor)
		print('==' .. os.getComputerLabel() .. ':/' .. DIR)
	end
	term.setTextColour(_colors.promptTextColor)
	term.write("$ " )
	term.setTextColour(_colors.commandTextColor)
	local sLine = shellRead(history)
	if bExit then -- terminated
		break
	end
	sLine = Util.trim(sLine)
	if #sLine > 0 and sLine ~= 'exit' then
		history:add(sLine)
	end
	term.setTextColour(_colors.textColor)
	if #sLine > 0 then
		local result, err = shell.run(sLine)
		local cx = term.getCursorPos()
		if cx ~= 1 then
			print()
		end
		term.setBackgroundColor(_colors.backgroundColor)
		if not result and err then
			_G.printError(err)
		end
	end
end

if oldTerm then
	term.redirect(oldTerm)
end
