if exists('g:autoloaded_tabby')
  finish
endif
let g:autoloaded_tabby = 1

" Table of Contents
" 1. Commands: Implement *:Tabby* commands
" 2. Settings: Handle global *Tabby-options*
" 3. Node Job: Manage node process, implement IO callbacks
" 4. Scheduler: Schedule completion requests
" 5. Completion UI: Show up completion, handle hotkeys
" 6. Utils: Utility functions

" 1. Commands
" See *:Tabby* in help document for more details.
"
" Notable script-local variables:
" - s:commmands
"   A dictionary contains all commands. Use name as key and function as value.
"

let s:commands = {}

function! s:commands.status(...)
  call tabby#Status()
endfunction

function! s:commands.enable(...)
  call tabby#Enable()
  call tabby#Status()
endfunction

function! s:commands.disable(...)
  call tabby#Disable()
  call tabby#Status()
endfunction

function! s:commands.toggle(...)
  call tabby#Toggle()
endfunction

function! s:commands.help(...)
  let args = get(a:, 1, [])
  if len(args) < 1
    execute 'help Tabby'
    return
  endif
  try
    execute 'help Tabby-' . join(args, '-')
    return
  catch
  endtry
  try
    execute 'help tabby_' . join(args, '_')
    return
  catch
  endtry
  execute 'help Tabby'
endfunction

function! tabby#CompleteCommands(arglead, cmd, pos)
  let words = split(a:cmd[0:a:pos].'#', ' ')
  if len(words) > 3
    return []
  endif
  if len(words) == 3
    if words[1] == 'help'
      let candidates = ['compatibility', 'commands', 'options', 'keybindings']
    else
      return []
    endif
  else
    let candidates = keys(s:commands)
  endif

  let end_index = len(a:arglead) - 1
  if end_index < 0
    return candidates
  else
    return filter(candidates, { idx, val ->
      \ val[0:end_index] == a:arglead
      \})
  endif
endfunction

function! tabby#Command(args)
  let args = split(a:args, ' ')
  if len(args) < 1
    call tabby#Status()
    echo 'Use :help Tabby to see available commands.'
    return
  endif
  if has_key(s:commands, args[0])
    call s:commands[args[0]](args[1:])
  else
    echo 'Unknown command'
  endif
endfunction

" 2. Settings
" See *Tabby-options* in help document for more details.
"
" Available global options:
" - g:tabby_enabled
" - g:tabby_suggestion_delay
" - g:tabby_filetype_to_languages
" - g:tabby_server_url
" - g:tabby_max_prefix_lines
" - g:tabby_max_suffix_lines
"

if !exists('g:tabby_enabled')
  let g:tabby_enabled = v:true
endif

if !exists('g:tabby_suggestion_delay')
  let g:tabby_suggestion_delay = 150
endif

if !exists('g:tabby_max_prefix_lines')
  let g:tabby_max_prefix_lines = 20
endif
if !exists('g:tabby_max_suffix_lines')
  let g:tabby_max_suffix_lines = 20
endif

if !exists('g:tabby_filetype_to_languages')
  " From: vim filetype https://github.com/vim/vim/blob/master/runtime/filetype.vim
  " To: vscode language identifier https://code.visualstudio.com/docs/languages/identifiers#_known-language-identifiers
  " Not listed filetype will be used as language identifier directly.
  let g:tabby_filetype_to_languages = {
    \ "bash": "shellscript",
    \ "cs": "csharp",
    \ "objc": "objective-c",
    \ "objcpp": "objective-cpp",
    \ }
endif

if !exists('g:tabby_server_url')
  let g:tabby_server_url = 'http://localhost:8080'
endif

if !exists('g:tabby_agent_logs')
  let g:tabby_agent_logs = 'error'
endif

" 3. Node Job
"
" Notable script-local variables:
" - s:tabby
"   Stores the job id of current node process
"
" - s:tabby_status
"   Syncs with status of node agent, updated by notification from agent
"
" - s:errmsg
"   Stores error message if self check failed before starting node process
"

function! tabby#Enable()
  let g:tabby_enabled = v:true
  if !tabby#IsRunning()
    call tabby#Start()
  endif
endfunction

function! tabby#Disable()
  let g:tabby_enabled = v:false
  if tabby#IsRunning()
    call tabby#Stop()
  endif
endfunction

function! tabby#Toggle()
  if g:tabby_enabled
    call tabby#Disable()
  else
    call tabby#Enable()
  endif
endfunction

function! tabby#Start()
  if !g:tabby_enabled || tabby#IsRunning()
    return
  endif

  let check_job = tabby#job#Check()
  if !check_job.ok
    let s:errmsg = check_job.message
    return
  endif

  let check_virtual_text = tabby#virtual_text#Check()
  if !check_virtual_text.ok
    let s:errmsg = check_virtual_text.message
    return
  endif
  call tabby#virtual_text#Init()

  if !executable('node')
    let s:errmsg = 'Tabby requires node to be installed.'
    return
  endif

  let tabby_root = expand('<script>:h:h')
  let node_script = tabby_root . '/node_scripts/tabby-agent.js'
  if !filereadable(node_script)
    let s:errmsg = 'Tabby node script should be download first. Try to run `yarn upgrade-agent`.'
    return
  endif

  let s:tabby_status = 'connecting'

  let command = 'node ' . node_script
  let s:tabby = tabby#job#Start(command, #{
    \ in_mode: 'json',
    \ out_mode: 'json',
    \ out_cb: function('s:HandleNotification'),
    \ err_cb: function('s:HandleError'),
    \ exit_cb: function('s:HandleExit'),
    \ })

  call s:Initialize()
endfunction

function! tabby#Stop()
  if tabby#IsRunning()
    call tabby#job#Stop(s:tabby)
  endif
endfunction

function! tabby#IsRunning()
  return exists('s:tabby')
endfunction

function! tabby#Status()
  if !g:tabby_enabled
    echo 'Tabby is disabled'
    return
  endif
  if tabby#IsRunning()
    if s:tabby_status == 'ready'
      echo 'Tabby is online'
    elseif s:tabby_status == 'connecting'
      echo 'Tabby is connecting to server'
    elseif s:tabby_status == 'disconnected'
      echo 'Tabby cannot connect to server'
    endif
  elseif exists('s:errmsg')
    echo s:errmsg
  else
    echo 'Tabby is enabled but not running'
  endif
endfunction

function! s:Initialize()
  if !tabby#IsRunning()
    return
  endif
  call tabby#job#Send(s:tabby, #{
    \ func: 'initialize',
    \ args: [#{
      \ config: #{
        \ server: #{ endpoint: g:tabby_server_url },
        \ logs: #{ level: g:tabby_agent_logs },
        \ },
      \ client: s:GetVersionString()
      \ }],
    \ })
endfunction

function! s:GetCompletion(id)
  if !tabby#IsRunning()
    return
  endif

  if exists('s:pending_request_id')
    call tabby#job#Send(s:tabby, #{
      \ func: 'cancelRequest',
      \ args: [s:pending_request_id],
      \ })
  endif

  let s:pending_request_id = tabby#job#Send(s:tabby, #{
    \ func: 'getCompletions',
    \ args: [s:CreateCompletionRequest()],
    \ }, #{
    \ callback: function('s:HandleCompletion', [a:id]),
    \ })
endfunction

function! s:PostEvent(event_type)
  if !tabby#IsRunning()
    return
  endif
  if !exists('s:completion') || !exists('s:choice_index')
    return
  endif
  call tabby#job#Send(s:tabby, #{
    \ func: 'postEvent',
    \ args: [#{
      \ type: a:event_type,
      \ completion_id: s:completion.id,
      \ choice_index: s:completion.choices[s:choice_index].index,
      \ }],
    \ })
endfunction

function! s:HandleNotification(channel, data)
  if (type(a:data) == v:t_dict) && has_key(a:data, 'event') && (a:data.event == 'statusChanged')
    let s:tabby_status = a:data.status
  endif
endfunction

function! s:HandleCompletion(id, channel, data)
  if !exists('s:trigger_id') || (a:id != s:trigger_id)
    return
  endif
  if (type(a:data) == v:t_dict) && has_key(a:data, 'choices') &&
    \ (type(a:data.choices) == v:t_list) && (len(a:data.choices) > 0)
    let s:completion = a:data
    let s:choice_index = 0
    call tabby#Show()
  endif
endfunction

function! s:HandleError(channel, data)
  " For Debug
  " echoerr "HandleError: " . string(a:data)
endfunction

function! s:HandleExit(channel, data)
  if exists('s:tabby')
    unlet s:tabby
  endif
  if exists('s:tabby_status')
    unlet s:tabby_status
  endif
endfunction

" 4. Scheduler
"
" Notable script-local variables:
" - s:scheduled:
"   Stores the timer id of current scheduled next trigger.
"
" - s:trigger_id:
"   Use a timestamp to identify current triggered completion request. This is
"   used to filter out outdated completion results.
"

function! tabby#Schedule()
  if !tabby#IsRunning()
    return
  endif
  call tabby#Clear()
  let s:scheduled = timer_start(g:tabby_suggestion_delay, function('tabby#Trigger'))
endfunction

function! tabby#Trigger(...)
  if !tabby#IsRunning()
    return
  endif
  call tabby#Clear()
  let id = join(reltime(), '.')
  let s:trigger_id = id
  call s:GetCompletion(id)
endfunction


" 5. Completion UI
"
" Notable script-local variables:
" - s:completion:
"   Stores current completion data, a dictionary that has same struct as server
"   returned completion response.
"
" - s:choice_index:
"   Stores index of current choice to display. A 0-based index of choice item
"   of `s:completion.choices` array, may not equals to the value of `index`
"   field `s:completion.choices[s:choice_index].index`.
"   A exception is that when `s:choice_index` is equal to the length of
"   `s:completion.choices`. In this state, the completion UI should show nothing
"   to notice users that they are cycling from last choice forward to first
"   choice, or from first choice back to last choice.
"   This variable does not change when user dismisses the completion UI.
"
" - s:shown_lines:
"   Stores the text that are shown in completion UI. `s:shown_lines` exists or
"   not means whether the completion UI is shown or not.
"
" - s:text_to_insert:
"   Used as a buffer to store the text that should be inserted when user accepts
"   the completion. We hide completion UI first and clear `s:shown_lines` at
"   same time, then insert the text, so that we need to store the text in a
"   buffer until text is inserted.


function! tabby#Show()
  call s:HideCompletion()
  if !s:IsCompletionAvailable()
    return
  endif
  if s:choice_index == len(s:completion.choices)
    " Show empty to indicate that user is cycling back to first choice.
    return
  endif
  let choice = s:completion.choices[s:choice_index]
  if (type(choice.text) != v:t_string) || (len(choice.text) == 0)
    return
  endif
  let lines = split(choice.text, "\n")
  " split will not give an empty line if text starts with "\n"
  if choice.text[0] == "\n"
    call insert(lines, '')
  endif
  " ignore trailing empty lines
  while len(lines) > 0 && (lines[-1] == '')
    call remove(lines, -1)
  endwhile
  call tabby#virtual_text#Show(lines)
  let s:shown_lines = lines
  call s:PostEvent('view')
endfunction

function! tabby#ConsumeInsertion()
  if !exists('s:text_to_insert')
    return ''
  else
    let text = s:text_to_insert
    unlet s:text_to_insert
    return text
  endif
endfunction

" This function is designed to replace <Tab> key input, so we need a fallback
" when completion UI is not shown.
function! tabby#Accept(...)
  if !exists('s:shown_lines')
    if a:0 < 1
      return "\<Ignore>"
    elseif type(a:1) == v:t_string
      return a:1
    elseif type(a:1) == v:t_func
      return call(a:1, [])
    endif
  endif
  let lines = s:shown_lines
  if len(lines) == 1
    let s:text_to_insert = lines[0]
    let insertion = "\<C-R>\<C-O>=tabby#ConsumeInsertion()\<CR>"
  else
    let suffix_chars_to_replace = strchars(getline(line('.'))[col('.') - 1:])
    let s:text_to_insert = join(lines, "\n")
    let insertion = repeat("\<Del>", suffix_chars_to_replace) . "\<C-R>\<C-O>=tabby#ConsumeInsertion()\<CR>\<End>"
  endif
  call s:HideCompletion()
  call s:PostEvent('select')
  return insertion
endfunction

" This function is designed to replace <C-]> key input, so we need a fallback
" when completion UI is not shown.
function! tabby#Dismiss(...)
  if !exists('s:shown_lines')
    if a:0 < 1
      return "\<Ignore>"
    elseif type(a:1) == v:t_string
      return a:1
    elseif type(a:1) == v:t_func
      return call(a:1, [])
    endif
  endif
  call s:HideCompletion()
  return ''
endfunction

function! tabby#Next()
  if !s:IsCompletionAvailable()
    return
  endif
  if !exists('s:shown_lines')
    if s:choice_index == len(s:completion.choices)
      let s:choice_index = 0
    endif
  else
    let s:choice_index += 1
    if s:choice_index > len(s:completion.choices)
      let s:choice_index = 0
    endif
  endif
  call tabby#Show()
endfunction

function! tabby#Prev()
  if !s:IsCompletionAvailable()
    return
  endif
  if !exists('s:shown_lines')
    if s:choice_index == len(s:completion.choices)
      let s:choice_index = len(s:completion.choices) - 1
    endif
  else
    let s:choice_index -= 1
    if s:choice_index < 0
      let s:choice_index = len(s:completion.choices)
    endif
  endif
  call tabby#Show()
endfunction

function! tabby#Clear()
  call s:HideCompletion()
  if exists('s:scheduled')
    call timer_stop(s:scheduled)
    unlet s:scheduled
  endif
  if exists('s:trigger_id')
    unlet s:trigger_id
  endif
  if exists('s:completion')
    unlet s:completion
  endif
  if exists('s:choice_index')
    unlet s:choice_index
  endif
endfunction

function! s:IsCompletionAvailable()
  if !exists('s:completion') || !exists('s:choice_index')
    return v:false
  endif
  if (type(s:completion.choices) != v:t_list) || (len(s:completion.choices) == 0)
    return v:false
  endif
  return v:true
endfunction

function! s:HideCompletion()
  call tabby#virtual_text#Clear()
  if exists('s:shown_lines')
    unlet s:shown_lines
  endif
endfunction

" 6. Utils

function! s:CreateCompletionRequest()
  return #{
    \ filepath: expand('%:p'),
    \ language: s:GetLanguage(),
    \ text: join(getbufline('%', 1, '$'), "\n"),
    \ position: s:CountCharsToCursor(),
    \ maxPrefixLines: g:tabby_max_prefix_lines,
    \ maxSuffixLines: g:tabby_max_suffix_lines,
    \ }
endfunction

function! s:CountCharsToCursor()
  let lines = getline(1, line('.') - 1)
  if col('.') > 1
    let lines += [getline(line('.'))[:col('.') - 2]]
  else
    let lines += ['']
  endif
  return strchars(join(lines, "\n"))
endfunction

function! s:GetLanguage()
  let filetype = getbufvar('%', '&filetype')
  if has_key(g:tabby_filetype_to_languages, filetype)
    return g:tabby_filetype_to_languages[filetype]
  else
    return filetype
  endif
endfunction

function! s:GetVersionString()
  let version_string = execute('version')
  return split(version_string, "\n")[0]
endfunction
