-- Copyright 2011-16 Paul Kulchenko, ZeroBrane LLC
-- authors: Lomtik Software (J. Winwood & John Labenski)
-- Luxinia Dev (Eike Decker & Christoph Kubisch)
---------------------------------------------------------

local ide = ide
local frame = ide.frame
local bottomnotebook = frame.bottomnotebook
local out = bottomnotebook.errorlog
local unpack = table.unpack or unpack

local MESSAGE_MARKER = StylesGetMarker("message")
local ERROR_MARKER = StylesGetMarker("error")
local PROMPT_MARKER = StylesGetMarker("prompt")
local PROMPT_MARKER_VALUE = 2^PROMPT_MARKER

local ffi = require 'ffi'

ffi.cdef[[
int MultiByteToWideChar(unsigned int CodePage,
    unsigned long dwFlags,
    const char* lpMultiByteStr,
    int cbMultiByte,
    wchar_t* lpWideCharStr,
    int cchWideChar);
]]

local CP_UTF8 = 65001
local CP_ACP = 0

local function toUnicode(input)
    local wlen = ffi.C.MultiByteToWideChar(CP_UTF8, 0, input, #input, nil, 0)
    local wstr = ffi.new('wchar_t[?]', wlen + 1)
    ffi.C.MultiByteToWideChar(CP_UTF8, 0, input, #input, wstr, wlen)
    return wstr, wlen
end

local config = ide.config.output

out:SetFont(ide:CreateFont(config.fontsize or 10, wx.wxFONTFAMILY_MODERN, wx.wxFONTSTYLE_NORMAL,
  wx.wxFONTWEIGHT_NORMAL, false, config.fontname or "",
  config.fontencoding or wx.wxFONTENCODING_DEFAULT)
)
out:SetCodePage(wxstc.wxSTC_CP_UTF8)
out:StyleSetFont(wxstc.wxSTC_STYLE_DEFAULT, out:GetFont())
out:StyleClearAll()
out:SetMarginWidth(1, 16) -- marker margin
out:SetMarginType(1, wxstc.wxSTC_MARGIN_SYMBOL)
out:SetTabWidth(2) -- second position from the end of the previous character
out:MarkerDefine(StylesGetMarker("message"))
out:MarkerDefine(StylesGetMarker("error"))
out:MarkerDefine(StylesGetMarker("prompt"))
out:SetReadOnly(true)
if config.usewrap then
  out:SetWrapMode(wxstc.wxSTC_WRAP_WORD)
  out:SetWrapStartIndent(0)
  out:SetWrapVisualFlags(wxstc.wxSTC_WRAPVISUALFLAG_END)
  out:SetWrapVisualFlagsLocation(wxstc.wxSTC_WRAPVISUALFLAGLOC_END_BY_TEXT)
end

function OutputAddStyles(styles)
  if ide.config.output.showansi and wxstc.wxSTC_LEX_ERRORLIST
  and type(ide.config.output.ansimap) == type({}) then
    out:SetLexer(wxstc.wxSTC_LEX_ERRORLIST)
    out:SetProperty("lexer.errorlist.escape.sequences","1")

    -- assign ansimap styles
    -- if this styles table is the same as the default one, then make a copy
    -- to avoid modifying all editor styles with "ansi" ones,
    -- as they will conflict with lexer-specific styles
    if ide.config.styles == styles then
      local stylecopy = StylesGetDefault()
      for k,v in pairs(styles) do stylecopy[k] = v end
      styles = stylecopy
      ide.config.stylesoutshell = styles
    end
    for k,v in pairs(ide.config.output.ansimap) do styles["ansi"..k] = v end
  end
end

OutputAddStyles(ide.config.stylesoutshell)
StylesApplyToEditor(ide.config.stylesoutshell,out)

function ClearOutput(force)
  if not (force or ide:GetMenuBar():IsChecked(ID.CLEAROUTPUTENABLE)) then return end
  out:SetReadOnly(false)
  out:ClearAll()
  out:SetReadOnly(true)
end

function out:Erase() ClearOutput(true) end

local inputBound = 0 -- to track where partial output ends for input editing purposes
local function getInputLine()
  return out:MarkerPrevious(out:GetLineCount()+1, PROMPT_MARKER_VALUE)
end
local function getInputText(bound)
  return out:GetTextRangeDyn(
    out:PositionFromLine(getInputLine())+(bound or 0), out:GetLength())
end
local function updateInputMarker()
  local lastline = out:GetLineCount()-1
  out:MarkerDeleteAll(PROMPT_MARKER)
  out:MarkerAdd(lastline, PROMPT_MARKER)
  inputBound = #getInputText()
end
function OutputEnableInput() updateInputMarker() end

function DisplayOutputNoMarker(...)
  local message = ""
  local cnt = select('#',...)
  for i=1,cnt do
    local v = select(i,...)
    message = message..tostring(v)..(i<cnt and "\t" or "")
  end

  local promptLine = getInputLine()
  local insertedAt = promptLine == wx.wxNOT_FOUND and out:GetLength() or out:PositionFromLine(promptLine) + inputBound
  local current = out:GetReadOnly()
  out:SetReadOnly(false)
  out:GotoPos(insertedAt)
  out:AddTextDyn(out.useraw and message or FixUTF8(message, "\022"))
  out:EmptyUndoBuffer()
  out:SetReadOnly(current)
  out:GotoPos(out:GetLength())
  out:EnsureVisibleEnforcePolicy(out:GetLineCount()-1)
  if promptLine ~= wx.wxNOT_FOUND then updateInputMarker() end
end
function DisplayOutput(...)
  local line = out:GetLineCount()-1
  DisplayOutputNoMarker(...)
  out:MarkerAdd(line, MESSAGE_MARKER)
end
function DisplayOutputLn(...)
  DisplayOutput(...)
  DisplayOutputNoMarker("\n")
end

function out:Print(...) return ide:Print(...) end
function out:Write(...) return DisplayOutputNoMarker(...) end
function out:Error(...)
  local line = out:GetLineCount()-1
  DisplayOutputNoMarker(...)
  DisplayOutputNoMarker("\n")
  out:MarkerAdd(line, ERROR_MARKER)
end

local streamins = {}
local streamerrs = {}
local streamouts = {}
local customprocs = {}
local textout = '' -- this is a buffer for any text sent to external scripts

function DetachChildProcess()
  for pid, custom in pairs(customprocs) do
    -- since processes are detached, their END_PROCESS event is not going
    -- to be called; call endcallback() manually if registered.
    if custom.endcallback then custom.endcallback(pid) end
    if custom.proc then custom.proc:Detach() end
  end
end

function CommandLineRunning(uid)
  for pid, custom in pairs(customprocs) do
    if (custom.uid == uid and custom.proc and custom.proc.Exists(tonumber(pid))) then
      return pid, custom.proc
    end
  end

  return
end

function CommandLineToShell(uid,state)
  for pid, custom in pairs(customprocs) do
    if (pid == uid or custom.uid == uid) and custom.proc and custom.proc.Exists(tonumber(pid)) then
      if (streamins[pid]) then streamins[pid].toshell = state end
      if (streamerrs[pid]) then streamerrs[pid].toshell = state end
      return true
    end
  end
end

-- logic to "unhide" wxwidget window using winapi
local ok, winapi = pcall(require, 'winapi')
if not ok then winapi = nil end
local checkstart, checknext, checkperiod
local pid = nil
local function unHideWindow(pidAssign)
  -- skip if not configured to do anything
  if not ide.config.unhidewindow then return end
  if pidAssign then
    pid = pidAssign > 0 and pidAssign or nil
  end
  if pid and winapi then -- pid provided and winapi loaded
    local now = ide:GetTime()
    if pidAssign and pidAssign > 0 then
      checkstart, checknext, checkperiod = now, now, 0.02
    end
    if now - checkstart > 1 and checkperiod < 0.5 then
      checkperiod = checkperiod * 2
    end
    if now >= checknext then
      checknext = now + checkperiod
    else
      return
    end
    local wins = winapi.find_all_windows(function(w)
      return w:get_process():get_pid() == pid
    end)
    local any = ide.interpreter and ide.interpreter.unhideanywindow
    local show, hide, ignore = 1, 2, 0
    for _,win in pairs(wins) do
      -- win:get_class_name() can return nil if the window is already gone
      -- between getting the list and this check.
      local action = ide.config.unhidewindow[win:get_class_name()]
        or (any and show or ignore)
      if action == show and not win:is_visible()
      or action == hide and win:is_visible() then
        -- use show_async call (ShowWindowAsync) to avoid blocking the IDE
        -- if the app is busy or is being debugged
        win:show_async(action == show and winapi.SW_SHOW or winapi.SW_HIDE)
        -- indicate that unhiding is done, but make sure to
        -- check late enough for all windows to get created
        if ide:GetTime() - ((customprocs[pid] or {}).started or 0) > 1 then
          pid = nil
        end
      end
    end
  end
end

local function nameTab(tab, name)
  local index = bottomnotebook:GetPageIndex(tab)
  if index ~= wx.wxNOT_FOUND then bottomnotebook:SetPageText(index, name) end
end

function OutputSetCallbacks(pid, proc, callback, endcallback)
  local streamin = proc and proc:GetInputStream()
  local streamerr = proc and proc:GetErrorStream()
  if streamin then
    streamins[pid] = {stream=streamin, callback=callback,
      proc=proc, check=proc and proc.IsInputAvailable}
  end
  if streamerr then
    streamerrs[pid] = {stream=streamerr, callback=callback,
      proc=proc, check=proc and proc.IsErrorAvailable}
  end
  customprocs[pid] = {proc=proc, endcallback=endcallback}
end

function CommandLineRun(cmd,wdir,tooutput,nohide,stringcallback,uid,endcallback, silent)
  if (not cmd) then return end
  local silent = silent or false

  -- expand ~ at the beginning of the command
  if ide.oshome and cmd:find('~') then
    cmd = cmd:gsub([[^(['"]?)~]], '%1'..ide.oshome:gsub('[\\/]$',''), 1)
  end

  local flagconsole = wx.wxEXEC_HIDE_CONSOLE

  if frame.menuBar:IsChecked(ID_SHOWCONSOLE) then
     tooutput = false
      nohide = true
      flagconsole = 2
  end
  -- try to extract the name of the executable from the command
  -- the executable may not have the extension and may be in quotes
  local exename = string.gsub(cmd, "\\", "/")
  local _,_,fullname = string.find(exename,'^[\'"]([^\'"]+)[\'"]')
  exename = fullname and string.match(fullname,'/?([^/]+)$')
    or string.match(exename,'/?([^/]-)%s') or exename

  uid = uid or exename
  local pid, proc = CommandLineRunning(uid)
  if pid then
    if silent then
      wx.wxProcess.Kill(pid, wx.wxSIGKILL, wx.wxKILL_CHILDREN)
      streamouts[pid] = nil
      customprocs[pid] = nil
    else
      DisplayOutputLn(TR("Program can't start because conflicting process is running as %s application.")
      :format(cmd))
      return
    end
  end
  if not silent then
    DisplayOutputLn(TR("Program starting as %s application."):format(ide.interpreter.name))
  end
  local proc = wx.wxProcess(out)
  if (tooutput) then proc:Redirect() end -- redirect the output if requested

  -- set working directory if specified
  local oldcwd
  if (wdir and #wdir > 0) then -- directory can be empty; ignore in this case
    oldcwd = wx.wxFileName.GetCwd()
    oldcwd = wx.wxFileName.SetCwd(wdir) and oldcwd
  end

  -- launch process
  local params = wx.wxEXEC_ASYNC + wx.wxEXEC_MAKE_GROUP_LEADER + wx.wxEXEC_NOHIDE + flagconsole
  local pid = wx.wxExecute(cmd, params, proc)

  if oldcwd then wx.wxFileName.SetCwd(oldcwd) end

  -- For asynchronous execution, the return value is the process id and
  -- zero value indicates that the command could not be executed.
  -- The return value of -1 in this case indicates that we didn't launch
  -- a new process, but connected to the running one (e.g. DDE under Windows).
  if not pid or pid == -1 or pid == 0 then
    DisplayOutputLn(TR("Program unable to run as %s application."):format(ide.interpreter.name))
    return
  end
  if not silent then
    DisplayOutputLn(TR("Program '%s' started in '%s' (pid: %d).")
    :format(uid, (wdir and wdir or wx.wxFileName.GetCwd()), pid))
  end
  OutputSetCallbacks(pid, proc, stringcallback, endcallback)
  customprocs[pid].uid=uid
  customprocs[pid].started = ide:GetTime()

  local streamout = proc and proc:GetOutputStream()
  if streamout then streamouts[pid] = {stream=streamout, callback=stringcallback, out=true, silent=silent} end

  if not nohide then
    unHideWindow(pid)
  end
  if not silent then
    nameTab(out, TR("Output (running)"))
  end
  return pid
end

ide:GetCodePage() -- populate the codepage value if auto-detection is requested

local readonce = 4096
local maxread = readonce * 10 -- maximum number of bytes to read before pausing
local function getStreams(all)
  local function readStream(tab)
    for _,v in pairs(tab) do
      -- periodically stop reading to get a chance to process other events
      local processed = 0
      while (v.check(v.proc) and (all or processed <= maxread)) do
        local str = v.stream:Read(readonce)
        -- the buffer has readonce bytes, so cut it to the actual size
        str = str:sub(1, v.stream:LastRead())
        processed = processed + #str

        local pfn
        if (v.callback) then
          str,pfn = v.callback(str)
        end
        if not str then
          -- skip if nothing to display
        elseif (v.toshell) then
          ide:GetConsole():Print(str)
        else
          str = str:gsub("\r\n", "\n")
          if bottomnotebook:GetPageText(bottomnotebook:GetPageIndex(out)) == "Output" then
            DisplayOutput(str)
          else
            DisplayOutputNoMarker(str)
          end
          if str and (getInputLine() ~= wx.wxNOT_FOUND or out:GetReadOnly()) then
            ide:GetOutput():SetFocus()
            ide:GetOutput():Activate()
            updateInputMarker()
          end
        end
        pfn = pfn and pfn()
      end
    end
  end
  local function sendStream(tab)
    local str = textout
    if not str then return end
    textout = nil
    for _,v in pairs(tab) do
      local pfn
      if v.callback then
        str,pfn = v.callback(str)
      end
      if str then
        str = str.."\n"
        v.stream:Write(str, #str)
      end
      updateInputMarker()
      pfn = pfn and pfn()
    end
  end
  readStream(streamins)
  readStream(streamerrs)
  sendStream(streamouts)
end

function out:ProcessStreams()
  if (#streamins or #streamerrs) then getStreams() end
end

out:Connect(wx.wxEVT_END_PROCESS, function(event)
    local pid = event:GetPid()
    local silent = streamouts[pid] and streamouts[pid].silent or false
    if (pid ~= -1) then
      getStreams(true)
      streamins[pid] = nil
      streamerrs[pid] = nil
      streamouts[pid] = nil

      if not customprocs[pid] then return end
      if customprocs[pid].endcallback then
        local ok, err = pcall(customprocs[pid].endcallback, pid, event:GetExitCode())
        if not ok then ide:GetOutput():Error(("Post processing execution failed: %s"):format(err)) end
      end
      
      -- if this was started with uid (`CommandLineRun`), then it needs additional processing
      if customprocs[pid].uid then
        -- delete markers and set focus to the editor if there is an input marker
        if out:MarkerPrevious(out:GetLineCount(), PROMPT_MARKER_VALUE) > wx.wxNOT_FOUND then
          out:MarkerDeleteAll(PROMPT_MARKER)
          local editor = ide:GetEditor()
          -- check if editor still exists; it may not if the window is closed
          if editor then editor:SetFocus() end
        end
        unHideWindow(0)
        ide:SetLaunchedProcess(nil)
        if not silent then
          nameTab(out, TR("Output"))
          DisplayOutputLn(TR("Program completed in %.2f seconds (pid: %d).")
          :format(ide:GetTime() - customprocs[pid].started, pid))
        end
      end
      -- this protects against the object referenced in wxProcess being collected
      -- before the wxProcess itself is collected, which may cause a crash on exit
      if customprocs[pid].proc then customprocs[pid].proc:Detach() end
      customprocs[pid] = nil
    end
  end)

out:Connect(wx.wxEVT_IDLE, function()
    out:ProcessStreams()
    if ide.osname == 'Windows' then unHideWindow() end
  end)

local function activateByPartialName(fname, jumpline, jumplinepos)
  -- fname may include name of executable, as in "path/to/lua: file.lua";
  -- strip it and try to find match again if needed.
  -- try the stripped name first as if it doesn't match, the longer
  -- name may have parts that may be interpreted as a network path and
  -- may take few seconds to check.
  local name
  local fixedname = fname:match(":%s+(.+)")
  if fixedname then
    name = GetFullPathIfExists(ide:GetProject(), fixedname)
      or FileTreeFindByPartialName(fixedname)
  end
  name = name
    or GetFullPathIfExists(ide:GetProject(), fname)
    or FileTreeFindByPartialName(fname)

  local editor = LoadFile(name or fname,nil,true)
  if not editor then
    local ed = ide:GetEditor()
    if ed and ide:GetDocument(ed):GetFileName() == (name or fname) then
      editor = ed
    end
  end
  if not editor then return false end

  jumpline = tonumber(jumpline)
  jumplinepos = tonumber(jumplinepos)

  editor:GotoPos(editor:PositionFromLine(math.max(0,jumpline-1))
    + (jumplinepos and (math.max(0,jumplinepos-1)) or 0))
  editor:EnsureVisibleEnforcePolicy(jumpline)
  editor:SetFocus()
  return true
end

out:Connect(wxstc.wxEVT_STC_DOUBLECLICK,
  function(event)
    local line = out:GetCurrentLine()
    local linetx = out:GetLineDyn(line)

    -- try to detect a filename and line in linetx
    for pattern, multiple in pairs(ide.config.output.lineactivate or {}) do
      local results = {}
      for fname, jumpline, jumplinepos in linetx:gmatch(pattern) do
        -- insert matches in reverse order (if any)
        table.insert(results, 1, {fname, jumpline, jumplinepos})
        if type(multiple) == "function" then results[1] = {multiple(unpack(results[1]))} end
        if multiple ~= true then break end -- one match is enough if no multiple is requested
      end
      for _, result in ipairs(results) do
        if activateByPartialName(unpack(result)) then
          -- doubleclick can set selection, so reset it
          local pos = event:GetPosition()
          if pos == wx.wxNOT_FOUND then pos = out:GetLineEndPosition(event:GetLine()) end
          out:SetSelection(pos, pos)
          return
        end
      end
    end
    event:Skip()
  end)

local function positionInLine(line)
  return out:GetCurrentPos() - out:PositionFromLine(line)
end
local function caretOnInputLine(disallowLeftmost)
  local inputLine = getInputLine()
  local boundary = inputBound + (disallowLeftmost and 0 or -1)
  return (out:GetCurrentLine() > inputLine
    or out:GetCurrentLine() == inputLine
   and positionInLine(inputLine) > boundary)
end

out:Connect(wx.wxEVT_KEY_DOWN,
  function (event)
    local key = event:GetKeyCode()
    if out:GetReadOnly() then
      -- no special processing if it's readonly
    elseif key == wx.WXK_UP or key == wx.WXK_NUMPAD_UP then
      if out:GetCurrentLine() <= getInputLine() then return end
    elseif key == wx.WXK_DOWN or key == wx.WXK_NUMPAD_DOWN then
      -- can go down
    elseif key == wx.WXK_LEFT or key == wx.WXK_NUMPAD_LEFT then
      if not caretOnInputLine(true) then return end
    elseif key == wx.WXK_BACK then
      if not caretOnInputLine(true) then return end
    elseif key == wx.WXK_DELETE or key == wx.WXK_NUMPAD_DELETE then
      if not caretOnInputLine()
      or out:LineFromPosition(out:GetSelectionStart()) < getInputLine() then
        return
      end
    elseif key == wx.WXK_PAGEUP or key == wx.WXK_NUMPAD_PAGEUP
        or key == wx.WXK_PAGEDOWN or key == wx.WXK_NUMPAD_PAGEDOWN
        or key == wx.WXK_END or key == wx.WXK_NUMPAD_END
        or key == wx.WXK_HOME or key == wx.WXK_NUMPAD_HOME
        or key == wx.WXK_RIGHT or key == wx.WXK_NUMPAD_RIGHT
        or key == wx.WXK_SHIFT or key == wx.WXK_CONTROL
        or key == wx.WXK_ALT then
      -- fall through
    elseif key == wx.WXK_RETURN or key == wx.WXK_NUMPAD_ENTER then
      if not caretOnInputLine()
      or out:LineFromPosition(out:GetSelectionStart()) < getInputLine() then
        return
      end
      out:GotoPos(out:GetLength()) -- move to the end
      textout = (textout or '') .. getInputText(inputBound)
      -- remove selection if any, otherwise the text gets replaced
      out:SetSelection(out:GetSelectionEnd()+1,out:GetSelectionEnd())
      -- don't need to do anything else with return
    else
      -- move cursor to end if not already there
      if not caretOnInputLine() then
        out:GotoPos(out:GetLength())
      -- check if the selection starts before the input line and reset it
      elseif out:LineFromPosition(out:GetSelectionStart()) < getInputLine(-1) then
        out:GotoPos(out:GetLength())
        out:SetSelection(out:GetSelectionEnd()+1,out:GetSelectionEnd())
      end
    end
    event:Skip()
  end)

local function inputEditable(line)
  local inputLine = getInputLine()
  local currentLine = line or out:GetCurrentLine()
  return inputLine ~= wx.wxNOT_FOUND and
    (currentLine > inputLine or
     currentLine == inputLine and positionInLine(inputLine) >= inputBound) and
    not (out:LineFromPosition(out:GetSelectionStart()) < getInputLine())
end

out:Connect(wxstc.wxEVT_STC_UPDATEUI, function() out:SetReadOnly(not inputEditable()) end)

-- only allow copy/move text by dropping to the input line
out:Connect(wxstc.wxEVT_STC_DO_DROP,
  function (event)
    if not inputEditable(out:LineFromPosition(event:GetPosition())) then
      event:SetDragResult(wx.wxDragNone)
    end
  end)

if config.nomousezoom then
  -- disable zoom using mouse wheel as it triggers zooming when scrolling
  -- on OSX with kinetic scroll and then pressing CMD.
  out:Connect(wx.wxEVT_MOUSEWHEEL,
    function (event)
      if wx.wxGetKeyState(wx.WXK_CONTROL) then return end
      event:Skip()
    end)
end
