--
--  Copyright 2019 The FATE Authors. All Rights Reserved.
--
--  Licensed under the Apache License, Version 2.0 (the "License");
--  you may not use this file except in compliance with the License.
--  You may obtain a copy of the License at
--
--      http://www.apache.org/licenses/LICENSE-2.0
--
--  Unless required by applicable law or agreed to in writing, software
--  distributed under the License is distributed on an "AS IS" BASIS,
--  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
--  See the License for the specific language governing permissions and
--  limitations under the License.
--

local schar = string.char
local ssub, gsub = string.sub, string.gsub
local sfind, smatch = string.find, string.match
local tinsert, tremove = table.insert, table.remove

local UNESCAPES = {
    ['0'] = "\x00", z = "\x00", N    = "\x85",
    a = "\x07",     b = "\x08", t    = "\x09",
    n = "\x0a",     v = "\x0b", f    = "\x0c",
    r = "\x0d",     e = "\x1b", ['\\'] = '\\',
}

-- help function
local function select(list, pred)
  local selected = {}
  for i = 0, #list do
    local v = list[i]
    if v and pred(v, i) then
      tinsert(selected, v)
    end
  end
  return selected
end

-- return: indent_count, left_string
local function count_indent(line)
  local _, j = sfind(line, '^%s+')
  if not j then
    return 0, line
  end
  return j, ssub(line, j+1)
end

local function trim(str)
    return string.gsub(str, "^%s*(.-)%s*$", "%1")
end

local function ltrim(str)
  return smatch(str, "^%s*(.-)$")
end

local function rtrim(str)
  return smatch(str, "^(.-)%s*$")
end

local function isemptyline(line)
    return line == '' or sfind(line, '^%s*$') or sfind(line, '^%s*#')
end

local function startswith(haystack, needle)
    return ssub(haystack, 1, #needle) == needle
end

local function startswithline(line, needle)
    return startswith(line, needle) and isemptyline(ssub(line, #needle+1))
end

-- class
local class = {__meta={}}
function class.__meta.__call(cls, ...)
  local self = setmetatable({}, cls)
  if cls.__init then
    cls.__init(self, ...)
  end
  return self
end

function class.def(base, type, cls)
  base = base or class
  local mt = {__metatable=base, __index=base}
  for k, v in pairs(base.__meta) do mt[k] = v end
  cls = setmetatable(cls or {}, mt)
  cls.__index = cls
  cls.__metatable = cls
  cls.__type = type
  cls.__meta = mt
  return cls
end

local types = {
  null = class:def('null'),
  map = class:def('map'),
  seq = class:def('seq'),
}

local Null = types.null
function Null.__tostring() return 'yaml.null' end
function Null.isnull(v)
  if v == nil then return true end
  if type(v) == 'table' and getmetatable(v) == Null then return true end
  return false
end
local null = Null()

-- implement function
local function parse_string(line, stopper)

    stopper = stopper or ''
    local q = ssub(line, 1, 1)
    if q == ' ' or q == '\t' then
      return parse_string(ssub(line, 2))
    end

    if q == "'" then
      local i = sfind(line, "'", 2, true)
      if not i then
        return nil, line
      end
      return ssub(line, 2, i-1), ssub(line, i+1)
    end

    if q == '"' then
      local i, buf = 2, ''
      while i < #line do
        local c = ssub(line, i, i)
        if c == '\\' then
          local n = ssub(line, i+1, i+1)
          if UNESCAPES[n] ~= nil then
            buf = buf..UNESCAPES[n]
          elseif n == 'x' then
            local h = ssub(i+2,i+3)
            if sfind(h, '^[0-9a-fA-F]$') then
              buf = buf..schar(tonumber(h, 16))
              i = i + 2
            else
              buf = buf..'x'
            end
          else
            buf = buf..n
          end
          i = i + 1
        elseif c == q then
          break
        else
          buf = buf..c
        end
        i = i + 1
      end
      return buf, ssub(line, i+1)
    end

    if q == '-' or q == ':' then
      if ssub(line, 2, 2) == ' ' or #line == 1 then
        return nil, line
      end
    end

    local buf = ''
    while #line > 0 do
      local c = ssub(line, 1, 1)
      if sfind(stopper, c, 1, true) then
        break
      elseif c == ':' and (ssub(line, 2, 2) == ' ' or #line == 1) then
        break
      elseif c == '#' and (ssub(buf, #buf, #buf) == ' ') then
        break
      else
        buf = buf..c
      end
      line = ssub(line, 2)
    end
    return rtrim(buf), line
end

local function parse_flowstyle(line, lines)
  local stack = {}
  while true do
    if #line == 0 then
      if #lines == 0 then
        break
      else
        line = tremove(lines, 1)
      end
    end
    local c = ssub(line, 1, 1)
    if c == '#' then
      line = ''
    elseif c == ' ' or c == '\t' or c == '\r' or c == '\n' then
      line = ssub(line, 2)
    elseif c == '{' or c == '[' then
      tinsert(stack, {v={},t=c})
      line = ssub(line, 2)
    elseif c == ':' then
      local s = tremove(stack)
      tinsert(stack, {v=s.v, t=':'})
      line = ssub(line, 2)
    elseif c == ',' then
      local value = tremove(stack)
      if value.t == ':' or value.t == '{' or value.t == '[' then error() end
      if stack[#stack].t == ':' then
        -- map
        local key = tremove(stack)
        stack[#stack].v[key.v] = value.v
      elseif stack[#stack].t == '{' then
        -- set
        stack[#stack].v[value.v] = true
      elseif stack[#stack].t == '[' then
        -- seq
        tinsert(stack[#stack].v, value.v)
      end
      line = ssub(line, 2)
    elseif c == '}' then
      if stack[#stack].t == '{' then
        if #stack == 1 then break end
        stack[#stack].t = '}'
        line = ssub(line, 2)
      else
        line = ','..line
      end
    elseif c == ']' then
      if stack[#stack].t == '[' then
        if #stack == 1 then break end
        stack[#stack].t = ']'
        line = ssub(line, 2)
      else
        line = ','..line
      end
    else
      local s, rest = parse_string(line, ',{}[]')
      if not s then
        error('invalid flowstyle line: '..line)
      end
      tinsert(stack, {v=s, t='s'})
      line = rest
    end
  end
  return stack[1].v, line
end

local function parse_scalar(line, lines)

    line = ltrim(line)
    line = gsub(line, '%s*#.*$', '')
  
    if line == '' or line == '~' then
      return null
    end
    
    if startswith(line, '{') or startswith(line, '[') then
      return parse_flowstyle(line, lines)
    end

    local s, _ = parse_string(line)
    if s and s ~= line then
      return s
    end

    -- Special cases
    if sfind('\'"!$', ssub(line, 1, 1), 1, true) then
      error('unsupported line: '..line)
    end
  
    if startswithline(line, '{}') then
      return {}
    end
    if startswithline(line, '[]') then
      return {}
    end
  
    -- Regular unquoted string
    local v = line
    if v == 'null' or v == 'Null' or v == 'NULL'then
      return null
    elseif v == 'true' or v == 'True' or v == 'TRUE' then
      return true
    elseif v == 'false' or v == 'False' or v == 'FALSE' then
      return false
    elseif v == '.inf' or v == '.Inf' or v == '.INF' then
      return math.huge
    elseif v == '+.inf' or v == '+.Inf' or v == '+.INF' then
      return math.huge
    elseif v == '-.inf' or v == '-.Inf' or v == '-.INF' then
      return -math.huge
    elseif v == '.nan' or v == '.NaN' or v == '.NAN' then
      return 0 / 0
    elseif sfind(v, '^[%+%-]?[0-9]+$') or sfind(v, '^[%+%-]?[0-9]+%.$')then
      return tonumber(v)
    elseif sfind(v, '^[%+%-]?[0-9]+%.[0-9]+$') then
      return tonumber(v)
    end
    return v
end

local parse_map

local function parse_seq(line, lines, indent)
  local seq = setmetatable({}, types.seq)
  if line ~= '' then
    error()
  end
  while #lines > 0 do
    line = lines[1]

    local level = count_indent(line)
    if level < indent and indent ~= -1 then
      return seq
    elseif level > indent and indent ~= -1 then
      error("found bad indenting in line: ".. line)
    end

    local i, j = sfind(line, '%-%s+')
    if not i then
      i, j = sfind(line, '%-$')
      if not i then
        return seq
      end
    end
    local rest = ssub(line, j+1)

    if sfind(rest, '^[^\'\"%s]*:') then
      local indent2 = j
      lines[1] = string.rep(' ', indent2)..rest
      tinsert(seq, parse_map('', lines, indent2))
    elseif isemptyline(rest) then
      tremove(lines, 1)
      if #lines == 0 then
        tinsert(seq, null)
        return seq
      end
      if sfind(lines[1], '^%s*%-') then
        local nextline = lines[1]
        local indent2 = count_indent(nextline)
        if indent2 == indent then
          tinsert(seq, null)
        else
          tinsert(seq, parse_seq('', lines, indent2))
        end
      else
        local nextline = lines[1]
        local indent2 = count_indent(nextline)
        tinsert(seq, parse_map('', lines, indent2))
      end
    elseif rest then
      tremove(lines, 1)
      local tmp = parse_scalar(rest, lines)
      tinsert(seq, tmp)
    end
  end
  return seq
end

function parse_map(line, lines, indent)
  if not isemptyline(line) then
    error('not map line: '..line)
  end
  local map = setmetatable({}, types.map)
  while #lines > 0 do
    line = lines[1]
  
    local level, _ = count_indent(line)
    if level < indent then
      return map
    elseif level > indent then
      error("found bad indenting in line: ".. line)
    end

    local key
    local s, rest = parse_string(line)
    if s and startswith(rest, ':') then
      local sc = parse_scalar(s, {})
      if sc and type(sc) ~= 'string' then
        key = sc
      else
        key = s
      end
      line = ssub(rest, 2)
    else
      error("failed to classify line: "..line)
    end

    if map[key] ~= nil then
      print("found a duplicate key '"..key.."' in line: "..line)
      local suffix = 1
      while map[key..'__'..suffix] do
        suffix = suffix + 1
      end
      key = key ..'_'..suffix
    end

    line = ltrim(line)

    if not isemptyline(line) then
      tremove(lines, 1)
      line = ltrim(line)
      map[key] = parse_scalar(line, lines)
    else
      tremove(lines, 1)
      if #lines == 0 then
        map[key] = null
        return map;
      end
      if sfind(lines[1], '^%s*%-') then
        local indent2 = count_indent(lines[1])
        map[key] = parse_seq('', lines, indent2)
      else
        local indent2 = count_indent(lines[1])
        if indent >= indent2 then
          map[key] = null
        else
          map[key] = parse_map('', lines, indent2)
        end
      end
    end
  end
  return map
end

local function parse_documents(lines)
    lines = select(lines, function(s) return not isemptyline(s) end)

    if #lines == 1 and not sfind(lines[1], '^%s*%-') then
      local line = lines[1]
      line = ltrim(line)
      return parse_scalar(line, lines)
    end

    local root = {}
    while #lines > 0 do
        local line = lines[1]
        if sfind(line, '^%s*%-') then
            tinsert(root, parse_seq('', lines, -1))
        elseif sfind(line, '^%s*[^%s]') then
            local level = count_indent(line)
            tinsert(root, parse_map('', lines, level))
        else
            error('parse error: '..line)
        end
    end

    if #root > 1 and Null.isnull(root[1]) then
        tremove(root, 1)
        return root
    end

    return root

end

local function parse(yaml)
    local lines = {}
    for line in string.gmatch(yaml..'\n', '(.-)\n') do
        table.insert(lines, line)
    end

    local docs = parse_documents(lines)
    if #docs == 1 then
        return docs[1]
    end
    return docs
end

return {
  null = null,
  parse = parse,
}

