" ============================================================================
" File:        mundo.vim
" Description: vim global plugin to visualize your undo tree
" Maintainer:  Hyeon Kim <simnalamburt@gmail.com>
" License:     GPLv2+ -- look it up.
" Notes:       Much of this code was thiefed from Mercurial, and the rest was
"              heavily inspired by scratch.vim and histwin.vim.
"
" ============================================================================


let s:save_cpo = &cpoptions
set cpoptions&vim

"{{{ Init

" Initialise global vars
let s:auto_preview_timer = -1"{{{
let s:preview_outdated = 1
let s:has_supported_python = 0
let s:has_timers = 0
let s:init_error = 'Initialisation failed due to an unknown error. '
            \ . 'Please submit a bug report :)'

" This has to be outside of a function, otherwise it just picks up the CWD
let s:plugin_path = escape(expand('<sfile>:p:h'), '\')"}}}

" Default to placeholder functions for exposed methods
function! mundo#MundoToggle() abort "{{{
    call mundo#util#Echo('WarningMsg',
                \ 'Mundo init error: ' . s:init_error)
endfunction

function! mundo#MundoShow() abort
    call mundo#util#Echo('WarningMsg',
                \ 'Mundo init error: ' . s:init_error)
endfunction

function! mundo#MundoHide() abort
    call mundo#util#Echo('WarningMsg',
                \ 'Mundo init error: ' . s:init_error)
endfunction
"}}}

" Check vim version
if v:version <? '703'"{{{
    let s:init_error = 'Vim version 7.03+ or later is required.'
    let &cpoptions = s:save_cpo
    finish
elseif v:version >=? '800' && has('timers')
    let s:has_timers = 1
endif"}}}

" Check python version
if g:mundo_prefer_python3 && has('python3')"{{{
    let s:has_supported_python = 2
elseif has('python')"
    let s:has_supported_python = 1
elseif has('python3')"
    let s:has_supported_python = 2
endif

if !s:has_supported_python
    let s:init_error = 'A supported python version was not found.'
    let &cpoptions = s:save_cpo
    finish
endif"}}}

" Python init methods
function! s:InitPythonModule(python)"{{{
    exe a:python .' import sys'
    exe a:python .' if sys.version_info[:2] < (2, 4): '.
                \ 'vim.command("let s:has_supported_python = 0")'
endfunction"}}}

function! s:MundoSetupPythonPath()"{{{
    if g:mundo_python_path_setup == 0
        let g:mundo_python_path_setup = 1
        call s:MundoPython('sys.path.insert(1, "'. s:plugin_path .'")')
        call s:MundoPython('sys.path.insert(1, "'. s:plugin_path .'/mundo")')
    end
endfunction"}}}
"}}}

"{{{ Mundo buffer settings

function! s:MundoMakeMapping(mapping, action)
    exec 'nnoremap <script> <silent> <buffer> ' . a:mapping .' '. a:action
endfunction

function! s:MundoMapGraph()"{{{
    for key in keys(g:mundo_mappings)
        let l:value = g:mundo_mappings[key]
        if l:value == "move_older"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPython('MundoMove(1,'. v:count .')')<CR>")
        elseif l:value == "move_newer"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPython('MundoMove(-1,'. v:count .')')<CR>")
        elseif l:value == "preview"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoRenderPreview(1)<CR>:<C-u> call <sid>MundoPythonRestoreView('MundoRevert()')<CR>")
        elseif l:value == "move_older_write"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPython('MundoMove(1,'.v:count.',True,True)')<CR>")
        elseif l:value == "move_newer_write"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPython('MundoMove(-1,'.v:count.',True,True)')<CR>")
        elseif l:value == "move_top"
            call s:MundoMakeMapping(key, "gg:<C-u>call <sid>MundoPython('MundoMove(1,'.v:count.')')<CR>")
        elseif l:value == "move_bottom"
            call s:MundoMakeMapping(key, "G:<C-u>call <sid>MundoPython('MundoMove(0,0)')<CR>:<C-u>call <sid>MundoRefresh()<CR>")
        elseif l:value == "play_to"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPythonRestoreView('MundoPlayTo()')<CR>zz")
        elseif l:value == "diff"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPythonRestoreView('MundoRenderPatchdiff()')<CR>")
        elseif l:value == "toggle_inline"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPythonRestoreView('MundoRenderToggleInlineDiff()')<CR>")
        elseif l:value == "search"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPython('MundoSearch()')<CR>")
        elseif l:value == "next_match"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPython('MundoNextMatch()')<CR>")
        elseif l:value == "previous_match"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPython('MundoPrevMatch()')<CR>")
        elseif l:value == "diff_current_buffer"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPythonRestoreView('MundoRenderChangePreview()')<CR>")
        elseif l:value == "diff"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoRenderPreview(1)<CR>")
        elseif l:value == "toggle_help"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoPython('MundoToggleHelp()')<CR>")
        elseif l:value == "quit"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoClose()<CR>")
        elseif l:value == "mouse_click"
            call s:MundoMakeMapping(key, ":<C-u>call <sid>MundoMouseDoubleClick()<CR>")
        endif
    endfor

    cabbrev  <script> <silent> <buffer> q     call <sid>MundoClose()
    cabbrev  <script> <silent> <buffer> quit  call <sid>MundoClose()
endfunction"}}}

function! s:MundoMapPreview()"{{{
    nnoremap <script> <silent> <buffer> q     :<C-u>call <sid>MundoClose()<CR>
    cabbrev  <script> <silent> <buffer> q     call <sid>MundoClose()
    cabbrev  <script> <silent> <buffer> quit  call <sid>MundoClose()
endfunction"}}}

function! s:MundoSettingsGraph()"{{{
    setlocal buftype=nofile
    setlocal bufhidden=hide
    setlocal noswapfile
    setlocal nobuflisted
    setlocal nomodifiable
    setlocal filetype=Mundo
    setlocal nolist
    setlocal nonumber
    setlocal norelativenumber
    setlocal nowrap
    call s:MundoSyntaxGraph()
    call s:MundoMapGraph()
endfunction"}}}

function! s:MundoSettingsPreview()"{{{
    setlocal buftype=nofile
    setlocal bufhidden=hide
    setlocal noswapfile
    setlocal nobuflisted
    setlocal nomodifiable
    setlocal filetype=MundoDiff
    setlocal syntax=diff
    setlocal nonumber
    setlocal norelativenumber
    setlocal nowrap
    setlocal foldlevel=20
    setlocal foldmethod=diff
    call s:MundoMapPreview()
endfunction"}}}

function! s:MundoSyntaxGraph()"{{{
    let b:current_syntax = 'mundo'
    syn match MundoCurrentLocation '@'
    syn match MundoHelp '\v^".*$'
    syn match MundoNumberField '\v\[[0-9]+\]'
    syn match MundoNumber '\v[0-9]+' contained containedin=MundoNumberField
    syn region MundoDiff start=/\v<ago> / end=/$/
    syn match MundoDiffAdd '\v\+[^+-]+\+' contained containedin=MundoDiff
    syn match MundoDiffDelete '\v-[^+-]+-' contained containedin=MundoDiff
    hi def link MundoCurrentLocation Keyword
    hi def link MundoHelp Comment
    hi def link MundoNumberField Comment
    hi def link MundoNumber Identifier
    hi def link MundoDiffAdd DiffAdd
    hi def link MundoDiffDelete DiffDelete
endfunction"}}}

"}}}

"{{{ Mundo buffer/window management

function! s:MundoResizeBuffers(backto)"{{{
    call mundo#util#GoToBuffer('__Mundo__')
    exe "vertical resize " . g:mundo_width

    call mundo#util#GoToBuffer('__Mundo_Preview__')
    exe "resize " . g:mundo_preview_height

    exe a:backto . "wincmd w"
endfunction"}}}

" Open the graph window. Assumes that the preview window is open.
function! s:MundoOpenGraph()"{{{
    if !mundo#util#GoToBuffer("__Mundo__")
        call assert_true(mundo#util#GoToBuffer('__Mundo_Preview__'))
        let existing_mundo_buffer = bufnr("__Mundo__")

        if existing_mundo_buffer == -1
            " Create buffer
            silent new __Mundo__

            if g:mundo_preview_bottom
                execute 'wincmd ' . (g:mundo_right ? 'L' : 'H')
            endif
        else
            " Open a window for existing buffer
            if g:mundo_preview_bottom
                let pos = (g:mundo_right ? 'botright' : 'topleft')
                silent execute pos.' vsplit +buffer' . existing_mundo_buffer
            else
                silent execute 'split +buffer' . existing_mundo_buffer
            endif
        endif

        call s:MundoResizeBuffers(winnr())
    endif

    if exists("g:mundo_tree_statusline")
        let &l:statusline = g:mundo_tree_statusline
    endif
endfunction"}}}

function! s:MundoOpenPreview()"{{{
    if !mundo#util#GoToBuffer("__Mundo_Preview__")
        let existing_preview_buffer = bufnr("__Mundo_Preview__")

        if existing_preview_buffer == -1
            " Create buffer
            if g:mundo_preview_bottom
                silent botright keepalt new __Mundo_Preview__
            else
                let pos = (g:mundo_right ? 'botright' : 'topleft')
                silent execute pos.' keepalt vnew __Mundo_Preview__'
            endif
        else
            " Open a window for existing buffer
            if g:mundo_preview_bottom
                silent execute 'botright keepalt split +buffer' .
                            \ existing_preview_buffer
            else
                let pos = (g:mundo_right ? 'botright' : 'topleft')
                silent execute pos.' keepalt vsplit +buffer' .
                            \ existing_preview_buffer
            endif
        endif
    endif

    if exists("g:mundo_preview_statusline")
        let &l:statusline = g:mundo_preview_statusline
    endif
endfunction"}}}

" Quits *all* open Mundo graph and preview windows.
function! s:MundoClose() abort
    let [l:tabid, l:winid] = win_id2tabwin(win_getid())

    " Close all graph and preview windows
    while mundo#util#GoToBufferGlobal('__Mundo__') ||
                \ mundo#util#GoToBufferGlobal('__Mundo_Preview__')
        quit
    endwhile

    " Attempt to return to previous window / tab or target buffer
    if win_gotoid(l:winid)
        return
    elseif l:tabid != 0 && l:tabid <= tabpagenr('$')
        execute 'normal! ' . l:tabid . 'gt'
    endif

    call mundo#util#GoToBuffer(get(g:, 'mundo_target_n', -1))
endfunction

" Returns 1 if the current buffer is a valid target buffer for Mundo, or a
" (falsy) string indicating the reason if otherwise.
function! s:MundoValidateBuffer()"{{{
    if !&modifiable
        let reason = 'is not modifiable'
    elseif &previewwindow
        let reason = 'is a preview window'
    elseif &buftype == 'help' || &buftype == 'quickfix' || &buftype == 'terminal'
        let reason = 'is a '.&buftype.' window'
    else
        return 1
    endif

    call mundo#util#Echo('None', 'Current buffer ('.bufnr('').') is not a '
                \ .'valid target for Mundo (Reason: '.reason.')')
    return 0
endfunction "}}}

" Returns True if the graph or preview windows are open in the current tab.
function! s:MundoIsVisible()"{{{
    return bufwinnr(bufnr("__Mundo__")) != -1 ||
                \ bufwinnr(bufnr("__Mundo_Preview__")) != -1
endfunction"}}}

" Open/reopen Mundo for the current buffer, initialising the python module if
" necessary.
function! s:MundoOpen() abort "{{{
    " Validate current buffer
    if !s:MundoValidateBuffer()
        return
    endif

    let g:mundo_target_n = bufnr('')
    call s:MundoClose()

    " Initialise python module if necessary
    if !exists('g:mundo_py_loaded')
        call s:MundoSetupPythonPath()

        if s:has_supported_python == 2
            exe 'py3file ' . escape(s:plugin_path, ' ') . '/mundo.py'
            call s:InitPythonModule('python3')
        else
            exe 'pyfile ' . escape(s:plugin_path, ' ') . '/mundo.py'
            call s:InitPythonModule('python')
        endif

        let g:mundo_py_loaded = 1
    endif

    " Save and reset `splitbelow` to avoid window positioning problems
    let saved_splitbelow = &splitbelow
    let &splitbelow = 0

    " Temporarily disable automatic previews until Mundo is opened
    let saved_auto_preview = g:mundo_auto_preview
    let g:mundo_auto_preview = 0

    " Create / open graph and preview windows
    call s:MundoOpenPreview()
    call mundo#util#GoToBuffer(g:mundo_target_n)
    call s:MundoOpenGraph()

    " Render the graph and preview, ensure the cursor is on a graph node
    call s:MundoPythonRestoreView('MundoRenderGraph(True)')
    call s:MundoRenderPreview()
    call s:MundoPython('MundoMove(0,0)')

    " Restore `splitbelow` and automatic preview option
    let &splitbelow = saved_splitbelow
    let g:mundo_auto_preview = saved_auto_preview
endfunction"}}}

function! s:MundoToggle()"{{{
    if s:MundoIsVisible()
        call s:MundoClose()
    else
        call s:MundoOpen()
    endif
endfunction"}}}

function! s:MundoShow()"{{{
    if !s:MundoIsVisible()
        call s:MundoOpen()
    endif
endfunction"}}}

function! s:MundoHide()"{{{
    call s:MundoSetupPythonPath()
    if s:MundoIsVisible()
        call s:MundoClose()
    endif
endfunction"}}}

"}}}

"{{{ Mundo mouse handling

function! s:MundoMouseDoubleClick()"{{{
    let start_line = getline('.')

    if stridx(start_line, '[') == -1
        return
    else
        call <sid>MundoPythonRestoreView('MundoRevert()')
    endif
endfunction"}}}

"}}}

"{{{ Mundo rendering

function! s:MundoPython(fn)"{{{
    exec "python".(s:has_supported_python == 2 ? '3' : '')." ". a:fn
endfunction"}}}

" Wrapper for MundoPython() that restores the window state and prevents other
" Mundo autocommands (with the exception of BufNewFile) from triggering.
function! s:MundoPythonRestoreView(fn)"{{{
    " Store view data, mode, window and 'evntignore' value
    let currentmode = mode()
    let currentWin = winnr()
    let winView = winsaveview()
    let eventignoreBack = &eventignore
    set eventignore=BufLeave,BufEnter,CursorHold,CursorMoved,TextChanged
                \,InsertLeave

    " Call python function
    call s:MundoPython(a:fn)

    " Restore view data
    execute currentWin .'wincmd w'
    call winrestview(winView)
    exec 'set eventignore='.eventignoreBack

    " Re-select visual selection
    if currentmode == 'v' || currentmode == 'V' || currentmode == ''
        execute 'normal! gv'
    endif
endfunction"}}}

" Accepts an optional integer that forces rendering if nonzero.
function! s:MundoRenderPreview(...)"{{{
    if !s:preview_outdated && (a:0 < 1 || !a:1)
        return
    endif

    call s:MundoPythonRestoreView('MundoRenderPreview()')
endfunction"}}}

"}}}

"{{{ Misc

" automatically reload Mundo buffer if open
function! s:MundoRefresh()"{{{
    " abort if Mundo is closed or cursor is in the preview window
    let mundoWin    = bufwinnr('__Mundo__')
    let mundoPreWin = bufwinnr('__Mundo_Preview__')
    let currentWin  = bufwinnr('%')

    if mundoWin == -1 || mundoPreWin == -1 || mundoPreWin == currentWin
        return
    endif

    " Disable the automatic preview delay if vim lacks support for timers
    if g:mundo_auto_preview_delay > 0 && !s:has_timers
        let g:mundo_auto_preview_delay = 0
        call mundo#util#Echo('WarningMsg',
                    \ 'The "g:mundo_auto_preview_delay" option requires'
                    \ .' support for timers. Please upgrade to either vim 8.0+'
                    \ .' (with +timers) or neovim to use this feature. Press '
                    \ .'any key to continue.')

        " Prevent the warning being cleared
        call getchar()
    endif

    " Handle normal refresh
    if g:mundo_auto_preview_delay <= 0
        call s:MundoPythonRestoreView('MundoRenderGraph()')

        if g:mundo_auto_preview && currentWin == mundoWin && mode() == 'n'
            call s:MundoRenderPreview()
        endif
        return
    endif

    " Handle delayed refresh
    call s:MundoRestartRefreshTimer()
endfunction"}}}

function! s:MundoRestartRefreshTimer()"{{{
    call s:MundoStopRefreshTimer()
    let s:auto_preview_timer = timer_start(
                \ get(g:, 'mundo_auto_preview_delay', 0),
                    \ function('s:MundoRefreshDelayed')
                \ )
endfunction"}}}

function! s:MundoStopRefreshTimer()"{{{
    if s:auto_preview_timer != -1
        call timer_stop(s:auto_preview_timer)
        let s:auto_preview_timer = -1
    endif
endfunction"}}}

function! s:MundoRefreshDelayed(...)"{{{
    " abort if Mundo is closed or cursor is in the preview window
    let mundoWin    = bufwinnr('__Mundo__')
    let mundoPreWin = bufwinnr('__Mundo_Preview__')
    let currentWin  = bufwinnr('%')

    if mundoWin == -1 || mundoPreWin == -1 || mundoPreWin == currentWin
        return
    endif

    " Update graph
    call s:MundoPythonRestoreView('MundoRenderGraph()')

    " Update preview
    if currentWin != mundoWin || !g:mundo_auto_preview
        return
    endif

    if mode() != 'n'
        call s:MundoRestartRefreshTimer()
        return
    endif

    call s:MundoRenderPreview()
endfunction"}}}

" Mark the preview as being up-to-date (0) or outdated (1)
function! mundo#MundoPreviewOutdated(outdated)"{{{
    if s:preview_outdated && !a:outdated
        call s:MundoStopRefreshTimer()
    endif

    let s:preview_outdated = a:outdated
endfunction"}}}

augroup MundoAug
    autocmd!
    autocmd BufEnter __Mundo__ call mundo#MundoPreviewOutdated(1)
    autocmd BufLeave __Mundo__
                \ if g:mundo_auto_preview |
                    \ call s:MundoRenderPreview() |
                    \ call s:MundoStopRefreshTimer() |
                \ endif |
    autocmd BufEnter __Mundo__ call s:MundoSettingsGraph()
    autocmd BufEnter __Mundo_Preview__ call s:MundoSettingsPreview()
    autocmd CursorHold,CursorMoved,TextChanged,InsertLeave *
                \ call s:MundoRefresh()
augroup END

"}}}

" Exposed functions{{{

function! mundo#MundoToggle()"{{{
    call s:MundoToggle()
endfunction"}}}

function! mundo#MundoShow()"{{{
    call s:MundoShow()
endfunction"}}}

function! mundo#MundoHide()"{{{
    call s:MundoHide()
endfunction"}}}

"}}}

let &cpoptions = s:save_cpo
unlet s:save_cpo