-- configs
local LogColors = {
    [LogDebug] = 'pink',
    [LogInfo] = 'white',
    [LogWarning] = 'yellow',
    [LogError] = 'red'
}
local MaxLogLines = 128
local MaxHistory = 1000

local oldenv = getfenv(0)
setfenv(0, _G)
_G.commandEnv = runinsandbox('commands')
setfenv(0, oldenv)

-- private variables
local terminalWindow
local terminalButton
local logLocked = false
local commandTextEdit
local terminalBuffer
local commandHistory = {}
local currentHistoryIndex = 0
local poped = false
local oldPos
local oldSize
local firstShown = false
local flushEvent
local cachedLines = {}
local disabled = false
local allLines = {}

-- private functions
local function navigateCommand(step)
    if commandTextEdit:isMultiline() then
        return
    end

    local numCommands = #commandHistory
    if numCommands > 0 then
        currentHistoryIndex = math.min(math.max(currentHistoryIndex + step, 0), numCommands)
        if currentHistoryIndex > 0 then
            local command = commandHistory[numCommands - currentHistoryIndex + 1]
            commandTextEdit:setText(command)
            commandTextEdit:setCursorPos(-1)
        else
            commandTextEdit:clearText()
        end
    end
end

local function completeCommand()
    local cursorPos = commandTextEdit:getCursorPos()
    if cursorPos == 0 then
        return
    end

    local commandBegin = commandTextEdit:getText():sub(1, cursorPos)
    local possibleCommands = {}

    -- create a list containing all globals
    local allVars = table.copy(_G)
    table.merge(allVars, commandEnv)

    -- match commands
    for k, v in pairs(allVars) do
        if k:sub(1, cursorPos) == commandBegin then
            table.insert(possibleCommands, k)
        end
    end

    -- complete command with one match
    if #possibleCommands == 1 then
        commandTextEdit:setText(possibleCommands[1])
        commandTextEdit:setCursorPos(-1)
        -- show command matches
    elseif #possibleCommands > 0 then
        print('>> ' .. commandBegin)

        -- expand command
        local expandedComplete = commandBegin
        local done = false
        while not done do
            cursorPos = #commandBegin + 1
            if #possibleCommands[1] < cursorPos then
                break
            end
            expandedComplete = commandBegin .. possibleCommands[1]:sub(cursorPos, cursorPos)
            for i, v in ipairs(possibleCommands) do
                if v:sub(1, #expandedComplete) ~= expandedComplete then
                    done = true
                end
            end
            if not done then
                commandBegin = expandedComplete
            end
        end
        commandTextEdit:setText(commandBegin)
        commandTextEdit:setCursorPos(-1)

        for i, v in ipairs(possibleCommands) do
            print(v)
        end
    end
end

local function doCommand(textWidget)
    local currentCommand = textWidget:getText()
    executeCommand(currentCommand)
    textWidget:clearText()
    return true
end

local function addNewline(textWidget)
    if not textWidget:isOn() then
        textWidget:setOn(true)
    end
    textWidget:appendText('\n')
end

local function onCommandChange(textWidget, newText, oldText)
    local _, newLineCount = string.gsub(newText, '\n', '\n')
    textWidget:setHeight((newLineCount + 1) * textWidget.baseHeight)

    if newLineCount == 0 and textWidget:isOn() then
        textWidget:setOn(false)
    end
end

local function onLog(level, message, time)
    if disabled then
        return
    end
    -- avoid logging while reporting logs (would cause a infinite loop)
    if logLocked then
        return
    end

    logLocked = true
    addLine(message, LogColors[level])
    logLocked = false
end

-- public functions
function init()
    terminalWindow = g_ui.displayUI('terminal')
    terminalWindow:setVisible(false)

    terminalWindow.onDoubleClick = popWindow

    terminalButton = modules.client_topmenu.addTopRightToggleButton('terminalButton', tr('Terminal') .. ' (Ctrl + T)',
                                                          '/images/topbuttons/terminal', toggle)
    Keybind.new("Misc.", "Toggle Terminal", "Ctrl+T", "")
    Keybind.bind("Misc.", "Toggle Terminal", {{
        type = KEY_DOWN,
        callback = toggle
    }})


    commandHistory = g_settings.getList('terminal-history')

    commandTextEdit = terminalWindow:getChildById('commandTextEdit')
    commandTextEdit:setHeight(commandTextEdit.baseHeight)
    connect(commandTextEdit, {
        onTextChange = onCommandChange
    })
    g_keyboard.bindKeyPress('Up', function()
        navigateCommand(1)
    end, commandTextEdit)
    g_keyboard.bindKeyPress('Down', function()
        navigateCommand(-1)
    end, commandTextEdit)
    g_keyboard.bindKeyPress('Ctrl+C', function()
        if commandTextEdit:hasSelection() or not terminalSelectText:hasSelection() then
            return false
        end
        g_window.setClipboardText(terminalSelectText:getSelection())
        return true
    end, commandTextEdit)
    g_keyboard.bindKeyDown('Tab', completeCommand, commandTextEdit)
    g_keyboard.bindKeyPress('Shift+Enter', addNewline, commandTextEdit)
    g_keyboard.bindKeyDown('Enter', doCommand, commandTextEdit)
    g_keyboard.bindKeyDown('Escape', hide, terminalWindow)

    terminalBuffer = terminalWindow:getChildById('terminalBuffer')
    terminalSelectText = terminalWindow:getChildById('terminalSelectText')
    terminalSelectText.onDoubleClick = popWindow
    terminalSelectText.onMouseWheel = function(a, b, c)
        terminalBuffer:onMouseWheel(b, c)
    end
    terminalBuffer.onScrollChange = function(self, value)
        terminalSelectText:setTextVirtualOffset(value)
    end

    g_logger.setOnLog(onLog)

    if not g_app.isRunning() then
        g_logger.fireOldMessages()
    elseif _G.terminalLines then
        for _, line in pairs(_G.terminalLines) do
            addLine(line.text, line.color)
        end
    end
end

function terminate()
    g_settings.setList('terminal-history', commandHistory)

    removeEvent(flushEvent)

    if poped then
        oldPos = terminalWindow:getPosition()
        oldSize = terminalWindow:getSize()
    end
    local settings = {
        size = oldSize,
        pos = oldPos,
        poped = poped
    }
    g_settings.setNode('terminal-window', settings)

    Keybind.delete("Misc.", "Toggle Terminal")
    g_logger.setOnLog(nil)
    terminalWindow:destroy()
    terminalButton:destroy()
    commandEnv = nil
    terminalWindow = nil
    terminalButton = nil

    _G.terminalLines = allLines
end

function hideButton()
    terminalButton:hide()
end

function popWindow()
    if poped then
        oldPos = terminalWindow:getPosition()
        oldSize = terminalWindow:getSize()
        terminalWindow:fill('parent')
        terminalWindow:setOn(false)
        terminalWindow:getChildById('bottomResizeBorder'):disable()
        terminalWindow:getChildById('rightResizeBorder'):disable()
        terminalWindow:getChildById('titleBar'):hide()
        terminalWindow:getChildById('terminalScroll'):setMarginTop(0)
        terminalWindow:getChildById('terminalScroll'):setMarginBottom(0)
        terminalWindow:getChildById('terminalScroll'):setMarginRight(0)
        poped = false
    else
        terminalWindow:breakAnchors()
        terminalWindow:setOn(true)
        local size = oldSize or {
            width = g_window.getWidth() / 2.5,
            height = g_window.getHeight() / 4
        }
        terminalWindow:setSize(size)
        local pos = oldPos or {
            x = 0,
            y = g_window.getHeight()
        }
        terminalWindow:setPosition(pos)
        terminalWindow:getChildById('bottomResizeBorder'):enable()
        terminalWindow:getChildById('rightResizeBorder'):enable()
        terminalWindow:getChildById('titleBar'):show()
        terminalWindow:getChildById('terminalScroll'):setMarginTop(18)
        terminalWindow:getChildById('terminalScroll'):setMarginBottom(1)
        terminalWindow:getChildById('terminalScroll'):setMarginRight(1)
        terminalWindow:bindRectToParent()
        poped = true
    end
end

function toggle()
    if terminalWindow:isVisible() then
        hide()
    else
        if not firstShown then
            local settings = g_settings.getNode('terminal-window')
            if settings then
                if settings.size then
                    oldSize = settings.size
                end
                if settings.pos then
                    oldPos = settings.pos
                end
                if settings.poped then
                    popWindow()
                end
            end
            firstShown = true
        end
        show()
    end
end

function show()
    terminalWindow:show()
    terminalWindow:raise()
    terminalWindow:focus()

    flushLines()
end

function hide()
    terminalWindow:hide()
end

function disable()
    terminalButton:hide()
    disabled = true
end

function flushLines()
    local fulltext = terminalSelectText:getText()

    local start = #cachedLines + 1 - MaxLogLines
    if start < 0 then
        start = 1
    end

    for i = start, #cachedLines do
        line = cachedLines[i]
        -- delete old lines if needed
        if terminalBuffer:getChildCount() >= MaxLogLines then
            local firstChild = terminalBuffer:getChildByIndex(1)
            if firstChild then
                local len = #firstChild:getText()
                firstChild:destroy()
                table.remove(allLines, 1)
                fulltext = string.sub(fulltext, len)
            end
        end

        local label = g_ui.createWidget('TerminalLabel', terminalBuffer)
        label:setId('terminalLabel' .. i)
        label:setText(line.text)
        label:setColor(line.color)

        table.insert(allLines, {
            text = line.text,
            color = line.color
        })

        fulltext = fulltext .. '\n' .. line.text
    end

    terminalSelectText:setText(fulltext)

    cachedLines = {}
    removeEvent(flushEvent)
    flushEvent = nil
end

function addLine(text, color)
    text = string.gsub(text, '\t', '    ')
    table.insert(cachedLines, {
        text = text,
        color = color
    })

    if terminalWindow:isVisible() and not flushEvent then
        flushEvent = scheduleEvent(flushLines, 10)
    end
end

function executeCommand(command)
    if command == nil or #string.gsub(command, '\n', '') == 0 then
        return
    end

    -- add command line
    addLine('> ' .. command, '#ffffff')

    -- reset current history index
    currentHistoryIndex = 0

    -- add new command to history
    if #commandHistory == 0 or commandHistory[#commandHistory] ~= command then
        table.insert(commandHistory, command)
        if #commandHistory > MaxHistory then
            table.remove(commandHistory, 1)
        end
    end

    -- detect and convert commands with simple syntax
    local realCommand
    if string.sub(command, 1, 1) == '=' then
        realCommand = 'print(' .. string.sub(command, 2) .. ')'
    else
        realCommand = command
    end

    local func, err = loadstring(realCommand, '@')

    -- detect terminal commands
    if not func then
        local command_name = command:match('^([%w_]+)[%s]*.*')
        if command_name then
            local args = string.split(command:match('^[%w_]+[%s]*(.*)'), ' ')
            if commandEnv[command_name] and type(commandEnv[command_name]) == 'function' then
                func = function()
                    modules.client_terminal.commandEnv[command_name](unpack(args))
                end
            elseif command_name == command then
                addLine('ERROR: command not found', 'red')
                return
            end
        end
    end

    -- check for syntax errors
    if not func then
        addLine('ERROR: incorrect lua syntax: ' .. err:sub(5), 'red')
        return
    end

    -- setup func env to commandEnv
    setfenv(func, commandEnv)

    -- execute the command
    local ok, ret = pcall(func)
    if ok then
        -- if the command returned a value, print it
        if ret then
            addLine(ret, 'white')
        end
    else
        addLine('ERROR: command failed: ' .. ret, 'red')
    end
end

function clear()
    terminalBuffer:destroyChildren()
    terminalSelectText:setText('')
    cachedLines = {}
    allLines = {}
end
