-- editor is a floating pane of editable code that accepts keyboard input
local m
m = {}  -- stores functions under keys and also all editors in a list
m.__index = m

m.active = nil  -- reference to the editor that receives text input
m.font = lovr.graphics.newFont('ubuntu-mono.ttf', 20)
m.font:setPixelDensity(1)
m.font_width = m.font:getWidth(' ')
m.font_height = m.font:getHeight()

local buffer = require'buffer'

local keymapping = {
  buffer = {
    ['up']                  = 'moveUp',
    ['down']                = 'moveDown',
    ['volume_down']         = 'moveLeft',
    ['volume_up']           = 'moveRight',
    ['left']                = 'moveLeft',
    ['ctrl+up']             = 'moveJumpUp',
    ['ctrl+down']           = 'moveJumpDown',
    ['ctrl+left']           = 'moveJumpLeft',
    ['ctrl+right']          = 'moveJumpRight',
    ['right']               = 'moveRight',
    ['home']                = 'moveHome',
    ['end']                 = 'moveEnd',
    ['pageup']              = 'movePageUp',
    ['pagedown']            = 'movePageDown',
    ['ctrl+home']           = 'moveJumpHome',
    ['ctrl+end']            = 'moveJumpEnd',

    ['shift+up']            = 'selectUp',
    ['alt+shift+up']        = 'selectJumpUp',
    ['shift+down']          = 'selectDown',
    ['alt+shift+down']      = 'selectJumpDown',
    ['shift+left']          = 'selectLeft',
    ['ctrl+shift+left']     = 'selectJumpLeft',
    ['ctrl+shift+right']    = 'selectJumpRight',
    ['shift+right']         = 'selectRight',
    ['shift+home']          = 'selectHome',
    ['shift+end']           = 'selectEnd',
    ['shift+pageup']        = 'selectPageUp',
    ['shift+pagedown']      = 'selectPageDown',

    ['tab']                 = 'insertTab',
    ['return']              = 'breakLine',
    ['enter']               = 'breakLine',
    ['delete']              = 'deleteRight',
    ['backspace']           = 'deleteLeft',
    ['ctrl+backspace']      = 'deleteWord',
    ['ctrl+x']              = 'cutText',
    ['ctrl+c']              = 'copyText',
    ['ctrl+v']              = 'pasteText',
  },
  macros = {
    ['ctrl+o']               = function(self) self:listFiles() end,
    ['ctrl+s']               = function(self) self:saveFile(self.path) end,
    ['ctrl+h']               = function(self) m.new(120, 60):openHelp() end,
    ['ctrl+shift+enter']     = function(self) self:execLine() end,
    ['ctrl+shift+return']    = function(self) self:execLine() end,
    ['ctrl+shift+home']      = function(self) self:center() end,
  },
}


local palette = { -- mariana color palette
  background   = 0x1f212b, --editor background
  cursorline   = 0x4c5863, --cursor background
  caret        = 0xeda550, --cursor
  whitespace   = 0x111111, --spaces, newlines, tabs, and carriage returns
  comment      = 0xa6acb9, --either multi-line or single-line comments
  string_start = 0x5fb4b4, --starts and ends of a string. There will be no non-string tokens between these two.
  string_end   = 0x5fb4b4,
  string       = 0x99c794, --part of a string that isn't an escape
  escape       = 0xc695c6, --a string escape, like \n, only found inside strings
  keyword      = 0xc695c6, --keywords. Like "while", "end", "do", etc
  value        = 0xec5f66, --special values. Only true, false, and nil
  ident        = 0x6699cc, --identifier. Variables, function names, etc
  number       = 0xf9ae58, --numbers, including both base 10 (and scientific notation) and hexadecimal
  symbol       = 0xa6acb9, --symbols, like brackets, parenthesis, ., .., etc
  vararg       = 0xec5f66, --...
  operator     = 0xf97b58, --operators, like +, -, %, =, ==, >=, <=, ~=, etc
  label_start  = 0x5fb4b4, --the starts and ends of labels. Always equal to '::'. Between them there can only be whitespace and label tokens.
  label_end    = 0x5fb4b4,
  label        = 0xc695c6, --basically an ident between a label_start and label_end.
  unidentified = 0xec5f66, --anything that isn't one of the above tokens. Consider them errors. Invalid escapes are also unidentified.
  selection    = 0x4c5863,
  active_bar   = 0xf97b58, -- editor sidebar
  disabled_bar = 0x4c5863,
}


local function drawRectangle(pass, col, row, columns, tokenType)
  local color = palette[tokenType] or tonumber(tokenType)
  pass:setColor(color)
  -- rectangle in text-coordinates
  local width = columns * m.font_width
  local height = m.font_height
  local x =  col * m.font_width
  local y = -row * m.font_height
  pass:plane(x + width / 2, y - height / 2, -2,  width, height)
end


local function drawToken(pass, text, col, row, tokenType)
  local color = palette[tokenType] or 0xFFFFFF
  -- in-editor preview of colors in hex format 0xRRGGBBAA
  if tokenType == 'number' and text:match('0x%x+') then
    drawRectangle(pass, col, row, 2, text)
  end
  pass:setColor(color)
  pass:setFont(m.font)
  local x =  col * m.font_width
  local y = -row * m.font_height
  pass:text(text, x,y,0, 1, 0, 1,0,0, 0, 'left','top')
end


function m.new(cols, rows, switchToProjectFn)
  local self = setmetatable({}, m)
  cols, rows = math.floor(cols), math.floor(rows)
  self.width = 1
  self.height = 1
  self.texture_size = 1024
  self.transform = lovr.math.newMat4(0, 1, -1, 1,1,1, 0, 0,1,0)
  self.ortho = lovr.math.newMat4()
  self.font:setPixelDensity(1)
  self.path = ''
  self.is_dirty = true
  self.switchToProject = switchToProjectFn or function() end
  self.buffer = buffer.new(cols, rows, drawToken, drawRectangle)
  self.pass = lovr.graphics.newPass()
  self:resize(cols, rows)
  self:center()
  table.insert(m, self)
  m.active = self
  return self
end


function m:resize(cols, rows)
  self.buffer.cols = cols
  self.buffer.rows = rows
  local status_lines = 1
  local texture_width  = self.buffer.cols * m.font_width
  local texture_height = (self.buffer.rows + status_lines) * m.font_height
  self.width  = texture_width  / 1000
  self.height = texture_height / 1000
  self.ortho:orthographic(0, texture_width, 0, -texture_height, -10, 10)
  self.texture = lovr.graphics.newTexture(texture_width, texture_height, {mipmaps=false})
  self.pass:setCanvas(self.texture)
  self:refresh()
end


function m:setText(content)
  self.buffer:setText(content)
end


function m:center()
  local x,y,z, angle, ax,ay,az = 0,0,0, 0, 1,0,0
  if lovr.headset then
    x,y,z, angle, ax,ay,az = lovr.headset.getPose('head')
  end
  local headTransform = mat4(x,y,z, angle, ax,ay,az)
  local headPosition = vec3(headTransform)
  local panePosition = vec3(headTransform:mul(0,0,-1.0))
  self.transform:target(panePosition, headPosition)
end


function m:openFile(filename_line)
  -- file path can optionally include line number to jump to, like 'main.lua:50'
  local filename, linenumber = filename_line:match('([^:]+):([%d]+)')
  filename = filename or filename_line
  local content = ' '
  if lovr.filesystem.isFile(filename) then
    content = lovr.filesystem.read(filename)
  end
  print('file open', filename, 'size', #content)
  self.buffer:setText(content)
  local coreFile = lovr.filesystem.getRealDirectory(filename) == lovr.filesystem.getSource()
  self.buffer:setName((coreFile and 'core! ' or '').. filename)
  self.path = filename
  self.buffer:jumpToLine(1, 0)
  self:refresh()
end


function m:removeFile(file_path)
  if lovr.filesystem.isFile(file_path) then
    print('deleting', file_path)
    lovr.filesystem.remove(file_path)
  else
    local err =  file_path .. ' does not exist'
    print('! ' .. err)
    error(err)
  end
end


function m:openHelp(path)
  self:listFiles('help')
end


function m:listFiles(path)
  if not path then -- try to use directory path of currently open file
    path = m.active and m.active.path:sub(1, (m.active.path:find('/[^/]+$') or 1) - 1) or ''
  end
  self.buffer:setText('')
  self.buffer:setName('FILES')
  self.path = ''
  --determine parent dir
  local previous = ''
  local parent = ''
  for subpath in path:gmatch('[^/]+/') do
    previous = subpath
    parent = parent .. previous
  end
  self.buffer:insertString('-- directory listing\n')
  if path ~= '' then
    parent = parent:sub(1, #parent - 1)
    self.buffer:insertString(string.format('self:listFiles(\'%s\') -- parent dir\n', parent))
  end
  --list directory items, first subdirectories then files
  local last_line = 2
  local files = {}
  local prefix = path == '' and '.' or path
  for _,filename in ipairs(lovr.filesystem.getDirectoryItems(path)) do
    local fullpath = prefix .. '/' .. filename
    if lovr.filesystem.isDirectory(fullpath) then
      self.buffer:insertString(string.format('self:listFiles(\'%s\')\n', fullpath))
      last_line = last_line + 1
    else
      table.insert(files, fullpath)
    end
  end
  if path == '' then -- root dir should only list directories and project-switching
    self.buffer:insertString('\n-- run project\n')
    for _,projname in ipairs(lovr.filesystem.getDirectoryItems('projects')) do
      self.buffer:insertString(string.format("self.switchToProject('%s')\n", projname))
    end
  else -- non-root dir shall list source/asset files and helpful commands
    for _, fullpath in ipairs(files) do
      self.buffer:insertString(string.format('self:openFile(\'%s\')\n', fullpath))
    end
    self.buffer:insertString('\n-- useful commands\n')
    self.buffer:insertString('self:openFile(\''..path..'/'..'new_source.lua\') -- new file\n')
    self.buffer:insertString('self:removeFile(\''..path..'/'..'unwanted.ext\') -- remove file\n')
  end
  self.buffer:insertString('\nctrl+shift+enter   confirm selection')
  self.buffer:jumpToLine(last_line, 0)
  self:refresh()
end


function m:saveFile(filename)
  local bytes
  filename = filename or self.path
  bytes = lovr.filesystem.write(filename, self.buffer:getText())
  self.path = filename
  self.buffer:setName(filename)
  print('file save', filename, 'size', bytes)
  return bytes
end


function m.storeSession(name)
  local bytes
  local scriptList = {}
  table.insert(scriptList, 'return {')
  for _, editor in ipairs(m) do
    if editor.path then
      table.insert(scriptList, '  {')
      table.insert(scriptList,  string.format("    path = '%s:%d',", tostring(editor.path), editor.buffer.cursor.y))
      table.insert(scriptList,  string.format('    pose = {%s}', table.concat({editor.transform:unpack()}, ', ')))
      table.insert(scriptList, '  },\n')
    end
  end
  table.insert(scriptList, '}\n')
  local content = table.concat(scriptList, '\n')
  bytes = lovr.filesystem.write(name .. '.lua', content)
end


function m.restoreSession(name)
  -- close editors, discarding potentially unsaved changes!
  for i = #m, 1, -1 do
    table.remove(m, i)
  end
  -- load in the stored session
  m.active = nil
  package.loaded[name] = nil
  local ok, session = pcall(require, name)
  if ok then
    for _, e in ipairs(session) do
      local editor
      editor = m.new(1, 1)
      editor:openFile(e.path)
      editor.transform:set(unpack(e.pose))
    end
  else
    print(session)
  end
end


function m:draw(pass)
  -- oriented towards -z so that mat4.target() works as expected
  pass:push()
  pass:transform(self.transform)
  -- background and side handle
  pass:setColor(palette.background)
  local margin = 0.02
  pass:plane(0,0, margin / 4, self.width + margin, self.height + margin, math.pi, 0,1,0)
  pass:setColor(self == m.active and palette.active_bar or palette.disabled_bar)
  local thickness = 0.02
  local handleX = self.width/2 + thickness / 2 + margin
  local handleY = 0
  pass:box(handleX, handleY, 0,  thickness, self.height * 0.8, thickness)
  -- code rendered from texture
  pass:setColor(1,1,1)
  pass:setMaterial(self.texture)
  pass:plane(0,0,0, -self.width, self.height)
  pass:setMaterial()
  pass:pop()
end


function m:drawToTexture()
  if not self.texture then
    self.texture = lovr.graphics.newTexture(self.texture_size, self.texture_size, {mipmaps=false})
    self.pass:setCanvas(self.texture)
  end
  self.pass:reset()
  self.pass:setDepthWrite(false)
  self.pass:setViewPose(1, mat4())
  self.pass:setProjection(1, self.ortho)
  self.pass:setColor(palette.background)
  self.pass:fill()
  self.buffer:drawCode(self.pass)
  return self.pass
end


function m:refresh()
  self.is_dirty = true
end


-- key handling
function m.keypressed(k)
  if m.active then
    -- execute buffer-mapped action for keypress
    if keymapping.buffer[k] then
      m.active.buffer[keymapping.buffer[k]](m.active.buffer)
    end
    -- execute macros
    if keymapping.macros[k] then
      print('executing macro for', k)
      keymapping.macros[k](m.active)
    end
    m.active:refresh()
  end
  if k == 'ctrl+tab' then     -- select next editor
    local lastEditor = m[#m]
    for _, editor in ipairs(m) do
      if editor == m.active then break end
      lastEditor = editor
    end
    m.active = lastEditor
  elseif k == 'ctrl+w' then       -- close current editor
    local lastEditor
    for i = #m, 1, -1 do
      if m[i] == m.active then
        table.remove(m, i)
      else
        lastEditor = lastEditor or m[i]
      end
    end
    m.active = lastEditor
  elseif k == 'ctrl+shift+s' then
    m.storeSession('indeck-session')
  elseif k == 'ctrl+shift+l' then
    m.restoreSession('indeck-session')
  end
end


function m.textinput(k)
  if m.active then
    m.active.buffer:insertCharacter(k)
    m.active:refresh()
  end
end


-- code execution environment

function m:execLine()
  local line = self.buffer:getCursorLine()
  local commentPos = line:find("%s+%-%-")
  if commentPos then
    line = line:sub(1, commentPos - 1)
  end
  local success, result = self:execUnsafely(line)
  if success then
    self.buffer.statusLine = 'ok' .. ' > ' .. (result or "")
  else
    print('! line exec error:', result)
    self.buffer.statusLine = 'fail' .. ' > ' .. (result or "")
  end
end


function m:execUnsafely(code)
  local userCode, err = loadstring(code)
  if not userCode then
    print('code error:', err)
    return false, err
  end
  -- set up current scope environment for user code execution
  local environment = {self=self, print=print, require=require, lovr=lovr}
  setfenv(userCode, environment)
  -- timber!
  return pcall(userCode)
end

return m
