" Array Callbacks:
" ================

function! sj#yaml#SplitArray()
  let [line, line_no, whitespace] = s:ReadCurrentLine()

  let prefix     = ''
  let array_part = ''
  let indent     = 1
  let end_offset = 0

  let nestedExp = '\v^\s*((-\s+)+)(\[.*\])$'
  let line = s:StripComment(line)

  " Split arrays which are map properties
  " E.g.
  "   prop: [1, 2]
  if line =~ ':\s*\[.*\]$'
    let [key, array_part] = s:SplitKeyValue(line)
    let prefix            = key . ":\n"

  " Split nested arrays
  " E.g.
  "   - [1, 2]
  elseif line =~ nestedExp
    let prefix     = substitute(line, nestedExp, '\1', '')
    let array_part = substitute(line, nestedExp, '\3', '')
    let indent     = len(substitute(line, '\v[^-]', '', 'g'))
    let end_offset = -1
  endif

  if array_part != ''
    let body        = substitute(array_part, '\v^\s*\[(.*)\]\s*$', '\1', '')
    let array_items = s:SplitArrayBody(body)

    call sj#ReplaceMotion('V', prefix . '- ' . join(array_items, "\n- "))
    silent! normal! zO
    call s:SetIndentWhitespace(line_no, whitespace)
    call s:IncreaseIndentWhitespace(line_no + 1, line_no + len(array_items) + end_offset, whitespace, indent)

    return 1
  endif

  return 0
endfunction

function! sj#yaml#JoinArray()
  let [line, line_no, whitespace] = s:ReadCurrentLine()

  let lines       = []
  let first_line  = s:StripComment(line)

  let nestedExp = '\v^(\s*(-\s+)+)(-\s+.*)$'
  let join_type = ''

  " Nested arrays
  " E.g.
  "   - - 'one'
  "     - 'two'
  if first_line =~ nestedExp && s:IsValidLineNo(line_no)
    let join_type = 'nested'
    let [lines, last_line_no] = s:GetChildren(line_no)
    let lines = map(lines, 's:StripComment(v:val)')
    let lines = [substitute(first_line, nestedExp, '\3', '')] + lines
    let first_line = sj#Rtrim(substitute(first_line, nestedExp, '\1', ''))

  " Normal arrays
  " E.g.
  "  list:
  "    - 'one'
  "    - 'two'
  elseif first_line =~ ':$' && s:IsValidLineNo(line_no + 1)
    let join_type = 'normal'
    let [lines, last_line_no] = s:GetChildren(line_no)
    let lines = map(lines, 's:StripComment(v:val)')
  endif

  if !empty(lines)
    if join_type == 'nested'
      let body_lines = lines[1:len(lines)]
    else
      let body_lines = lines
    endif

    for line in body_lines
      if line !~ '^\s*$' && line !~ '^\s*-'
        " one non-blank line is not part of the array, it must be a nested
        " construct, can't handle that
        return 0
      endif

      if line =~ nestedExp
        " can't handle nested subexpressions
        return 0
      endif
    endfor

    let lines       = map(lines, 'sj#Trim(substitute(v:val, "^\\s*-", "", ""))')
    let lines       = filter(lines, '!sj#BlankString(v:val)')
    let replacement = first_line . ' [' . s:JoinArrayItems(lines) . ']'

    call sj#ReplaceLines(line_no, last_line_no, replacement)
    silent! normal! zO
    call s:SetIndentWhitespace(line_no, whitespace)

    return 1
  endif

  " then there's nothing to join
  return 0
endfunction

" Map Callbacks:
" ================

function! sj#yaml#SplitMap()
  let [from, to] = sj#LocateBracesOnLine('{', '}')

  if from >= 0 && to >= 0
    let [line, line_no, whitespace] = s:ReadCurrentLine()
    let line  = s:StripComment(line)
    let pairs = sj#ParseJsonObjectBody(from + 1, to - 1)
    let body  = join(pairs, "\n")
    let body_start = line_no

    let indent_level = 0
    let end_offset   = -1

    " Increase indention if the map is inside a nested array.
    " E.g.
    "   - - { one: 1 }
    if line =~ '^\s*-\s'
      let indent_level = s:NestedArrayLevel(line)
    endif

    " Move body into next line if it is a map property.
    " E.g.
    "   prop: { one: 1 }
    "   - prop: { one: 1 }
    if line =~ '^\v\s*(-\s+)*[^{]*:\s+\{.*'
      let body          = "\n" . body
      let indent_level += 1
      let end_offset    = 0
      let body_start    = line_no + 1
    endif

    call sj#ReplaceMotion('Va{', body)
    silent! normal! zO
    call s:SetIndentWhitespace(line_no, whitespace)
    call s:IncreaseIndentWhitespace(line_no + 1, line_no + len(pairs) + end_offset, whitespace, indent_level)
    call sj#Keeppatterns(line_no . 's/\s*$//e')

    if sj#settings#Read('align')
      let body_end = body_start + len(pairs) - 1
      call sj#Align(body_start, body_end, 'json_object')
    endif

    return 1
  endif

  return 0
endfunction

function! sj#yaml#JoinMap()
  let [line, line_no, whitespace] = s:ReadCurrentLine()

  if !s:IsValidLineNo(line_no + 1)
    return 0
  endif

  let first_line   = s:StripComment(line)
  let lines        = []
  let last_line_no = 0
  let join_type    = ''

  let nestedExp     = '\v^(\s*(-\s+)+)(.*)$'
  let nestedPropExp = '\v^(\s*(-\s+)+.+:)$'

  " Nested in a map inside an array.
  " E.g.
  "  - prop:
  "     one: 1
  if first_line =~ nestedPropExp
    let join_type = 'nested_in_map_in_array'
    let [lines, last_line_no] = s:GetChildren(line_no)
    let first_line = sj#Rtrim(substitute(first_line, nestedPropExp, '\1', ''))

  " Map inside an array.
  " E.g.
  "  - one: 1
  "    two: 2
  elseif first_line =~ nestedExp
    let join_type = 'nested_in_array'
    let [lines, last_line_no] = s:GetChildren(line_no)
    let lines = [substitute(first_line, nestedExp, '\3', '')] + lines
    let first_line = sj#Rtrim(substitute(first_line, nestedExp, '\1', ''))

    if len(lines) <= 1
      " only 1 line means nothing to join in this case
      return 0
    endif

  " Normal map
  " E.g.
  "   map:
  "     one: 1
  "     two: 2
  elseif first_line =~ '\k\+:\s*$'
    let join_type = 'normal'
    let [lines, last_line_no] = s:GetChildren(line_no)
  endif

  if len(lines) > 0
    if join_type == 'nested_in_array'
      let body_lines = lines[1:len(lines)]
    else
      let body_lines = lines
    endif

    if len(body_lines) > 0
      let base_indent = len(matchstr(body_lines[0], '^\s*'))
    endif

    for line in body_lines
      if line =~ '^\s*-'
        " one of the lines is a part of an array, we can't handle nested subexpressions
        return 0
      endif

      if len(matchstr(line, '^\s*')) != base_indent
        " a nested map, can't handle that
        return 0
      endif
    endfor

    let lines = sj#TrimList(lines)
    let lines = s:NormalizeWhitespace(lines)
    let lines = map(lines, 's:StripComment(v:val)')

    if sj#settings#Read('curly_brace_padding')
      let replacement = first_line . ' { '. join(lines, ', ') . ' }'
    else
      let replacement = first_line . ' {'. join(lines, ', ') . '}'
    endif

    call sj#ReplaceLines(line_no, last_line_no, replacement)
    silent! normal! zO
    call s:SetIndentWhitespace(line_no, whitespace)

    return 1
  endif

  return 0
endfunction

" Helper Functions:
" =================

" Reads line, line number and indention
function! s:ReadCurrentLine()
  let line_no    = line('.')
  let line       = getline(line_no)
  let whitespace = s:GetIndentWhitespace(line_no)

  return [line, line_no, whitespace]
endfunction

" Strip comments from string starting with a #
function! s:StripComment(s)
  return substitute(a:s, '\v\s+#.*$', '', '')
endfunction

" Check if current buffer has the line number
function! s:IsValidLineNo(no)
  return a:no >= 0  && a:no <= line('$')
endfunction

" Normalize whitespace, if enabled
function! s:NormalizeWhitespace(lines)
  if sj#settings#Read('normalize_whitespace')
    return map(a:lines, 'substitute(v:val, ":\\s\\+", ": ", "")')
  endif
  return a:lines
endfunction

function! s:GetIndentWhitespace(line_no)
  return substitute(getline(a:line_no), '^\(\s*\).*$', '\1', '')
endfunction

function! s:SetIndentWhitespace(line_no, whitespace)
  silent call sj#Keeppatterns(a:line_no . 's/^\s*/' . a:whitespace)
endfunction

function! s:IncreaseIndentWhitespace(from, to, whitespace, level)
  if a:whitespace =~ "\t"
    let new_whitespace = a:whitespace . repeat("\t", a:level)
  else
    let new_whitespace = a:whitespace . repeat(' ', &sw * a:level)
  endif

  for line_no in range(a:from, a:to)
    call s:SetIndentWhitespace(line_no, new_whitespace)
  endfor
endfunction

" Get following lines with a greater indent than the current line
function! s:GetChildren(line_no)
  let line_no      = a:line_no
  let next_line_no = line_no + 1
  let indent       = indent(line_no)
  let next_line    = getline(next_line_no)

  " Count '- ' as indent, if an object is in an array
  " E.g. (GetChildren for prop_a)
  "   list:
  "     - prop_a:
  "         - 1
  "       prop_b
  "         - 2
  let line = getline(line_no)
  if line =~ '^\s*\(\-\s\s*\)..*:$'
    let prefix = substitute(getline(a:line_no), '^\s*\(\-\s\s*\)..*:$', '\1', '')
    let indent += len(prefix)
  end

  while s:IsValidLineNo(next_line_no) &&
        \ (sj#BlankString(next_line) || indent(next_line_no) > indent)
    let next_line_no = next_line_no + 1
    let next_line    = getline(next_line_no)
  endwhile
  let next_line_no = next_line_no - 1

  " Preserve trailing empty lines
  while sj#BlankString(getline(next_line_no)) && next_line_no > line_no
    let next_line_no = next_line_no - 1
  endwhile

  return [sj#GetLines(line_no + 1, next_line_no), next_line_no]
endfunction

" Split a string into individual array items.
" E.g.
"   'one, two'               => ['one', 'two']
"   '{ one: 1 }, { two: 2 }' => ['{ one: 1 }', '{ two: 2 }']
function! s:SplitArrayBody(body)
  let items = []

  let partial_item = ''
  let rest = sj#Ltrim(a:body)

  while !empty(rest)
    let char = rest[0]

    if char == '{'
      let [item, rest] = s:ReadMap(rest)
      let rest = s:SkipWhitespaceUntilComma(rest)

      call add(items, s:StripCurlyBrackets(item))

    elseif char == '['
      let [item, rest] = s:ReadArray(rest)
      let rest = s:SkipWhitespaceUntilComma(rest)

      call add(items, sj#Trim(item))

    elseif char == '"' || char == "'"
      let [item, rest] = s:ReadString(rest)
      let rest = s:SkipWhitespaceUntilComma(rest)

      call add(items, sj#Trim(item))

    else
      let [item, rest] = s:ReadUntil(rest, ',')
      call add(items, sj#Trim(item))
    endif

    let rest = sj#Ltrim(rest[1:])
  endwhile

  return items
endfunction

" Read string until occurence of end_char
function! s:ReadUntil(str, end_char)
  let idx = 0
  while idx < len(a:str)
    if a:str[idx] == a:end_char
      return idx == 0
        \ ? ['', a:str[1:]]
        \ : [a:str[:idx-1], a:str[idx+1:]]
    endif

    let idx += 1
  endwhile

  return [a:str, '']
endfunction

" Read the next string fenced by " or '
function! s:ReadString(str)
  if len(a:str) > 0
    let fence = a:str[0]
    if fence == '"' || fence == "'"
      let [str, rest] = s:ReadUntil(a:str[1:], fence)
      return [fence . str . fence, rest]
    endif
  endif

  return ['', a:str]
endfunction

" Read the next array, including nested arrays.
" E.q.
"  '[[1, 2]], [1]' => ['[[1, 2]], ', [1]']
function! s:ReadArray(str)
  return s:ReadContainer(a:str, '[', ']')
endfunction

" Read the next map, including nested maps.
" E.q.
"  '{ one: 1, foo: { two: 2 } }, {}' => ['{ one: 1, foo: { two: 2 } }, ', {}']
function! s:ReadMap(str)
  return s:ReadContainer(a:str, '{', '}')
endfunction

function! s:ReadContainer(str, start_char, end_char)
  let content = ''
  let rest = a:str
  let depth = 0

  while !empty(rest)
    let char = rest[0]
    let rest = rest[1:]

    let content .= char

    if char == a:start_char
      let depth += 1
    elseif char == a:end_char
      let depth -= 1

      if depth == 0 | break | endif
    endif
  endwhile

  return [content, rest]
endfunction

" skip whitespace and next comma
function! s:SkipWhitespaceUntilComma(str)
  let [space, rest] = s:ReadUntil(a:str, ',')

  if !sj#BlankString(space)
    throw '"' . space . '" is not whitespace!'
  end
  return rest
endfunction

function! s:JoinArrayItems(items)
  return join(map(a:items, 's:AddCurlyBrackets(v:val)'), ', ')
endfunction

" Add curly brackets if required for joining
" E.g.
"   'one: 1' => '{ one: 1 }'
"   'one'    => 'one'
function! s:AddCurlyBrackets(line)
  let line = sj#Trim(a:line)

  if line !~ '^\v\[.*\]$' && line !~ '^\v\{.*\}$'
    let [key, value] = s:SplitKeyValue(line)
    if key != ''
      return '{ ' . a:line . ' }'
    endif
  endif

  return a:line
endfunction

" Strip curly brackets if possible
" E.g.
"   '{ one: 1 }'         => 'one: 1'
"   '{ one: 1, two: 2 }' => '{ one: 1, two: 2 }'
function! s:StripCurlyBrackets(item)
  let item = sj#Trim(a:item)

  if item =~ '^{.*}$'
    let parser = sj#argparser#js#Construct(2, len(item) - 1, item)
    call parser.Process()

    if len(parser.args) == 1
      let item = substitute(item, '^{\s*', '', '')
      let item = substitute(item, '\s*}$', '', '')
    endif
  endif

  return item
endfunction

" Split a string into key and value
" E.g.
"   'one: 1' => ['one', '1']
"   'one'    => ['', 'one']
"   'one:'   => ['one', '']
"   'a:val'  => ['', 'a:val']
function! s:SplitKeyValue(line)
  let line = sj#Trim(a:line)
  let parts = []

  let first_char = line[0]

  let key   = ''
  let value = ''

  " Read line starts with a fenced string. E.g
  "   'one': 1
  "   'one'
  if first_char == '"' || first_char == "'"
    let [key, rest] = s:ReadString(line)
    let [_, value]  = s:ReadUntil(rest, ':')
  else
    let parts = split(line . ' ', ': ')
    let [key, value] = [parts[0], join(parts[1:], ': ')]
  endif

  if value == '' && a:line !~ '\s*:$'
    let [key, value] = ['', key]
  endif

  return [sj#Trim(key), sj#Trim(value)]
endfunction

" Calculate the nesting level of an array item
" E.g.
"   - foo    => 1
"   - - bar  => 2
function! s:NestedArrayLevel(line)
  let prefix = substitute(a:line, '\v^\s*((-\s+)+).*', '\1', '')
  let levels = substitute(prefix, '[^-]', '', 'g')
  return len(levels)
endfunction