-------------------------------------------------------------------------------
-- tinyyaml - YAML subset parser
-- https://github.com/peposso/lua-tinyyaml
-------------------------------------------------------------------------------
local table = table
local string = string
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 setmetatable = setmetatable
local pairs = pairs
local type = type
local tonumber = tonumber
local math = math
local getmetatable = getmetatable
local error = error

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

-------------------------------------------------------------------------------
-- utils
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

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

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

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

-------------------------------------------------------------------------------
-- Implementation.
--
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, typ, 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 = typ
    cls.__meta = mt
    return cls
end

local types = {
    null = class:def('null'),
    map = class:def('map'),
    omap = class:def('omap'),
    pairs = class:def('pairs'),
    set = class:def('set'),
    seq = class:def('seq'),
    timestamp = class:def('timestamp')
}

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()

function types.timestamp:__init(y, m, d, h, i, s, f, z)
    self.year = tonumber(y)
    self.month = tonumber(m)
    self.day = tonumber(d)
    self.hour = tonumber(h or 0)
    self.minute = tonumber(i or 0)
    self.second = tonumber(s or 0)
    if type(f) == 'string' and sfind(f, '^%d+$') then
        self.fraction = tonumber(f) * math.pow(10, 3 - #f)
    elseif f then
        self.fraction = f
    else
        self.fraction = 0
    end
    self.timezone = z
end

function types.timestamp:__tostring()
    return string.format('%04d-%02d-%02dT%02d:%02d:%02d.%03d%s', self.year, self.month, self.day, self.hour,
        self.minute, self.second, self.fraction, self:gettz())
end

function types.timestamp:gettz()
    if not self.timezone then
        return ''
    end
    if self.timezone == 0 then
        return 'Z'
    end
    local sign = self.timezone > 0
    local z = sign and self.timezone or -self.timezone
    local zh = math.floor(z)
    local zi = (z - zh) * 60
    return string.format('%s%02d:%02d', sign and '+' or '-', zh, zi)
end

local function countindent(line)
    local _, j = sfind(line, '^%s+')
    if not j then
        return 0, line
    end
    return j, ssub(line, j + 1)
end

local function parsestring(line, stopper)
    stopper = stopper or ''
    local q = ssub(line, 1, 1)
    if q == ' ' or q == '\t' then
        return parsestring(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 -- flow style
        return nil, line
    end
    if q == '|' or q == '>' then -- block
        return nil, line
    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 isemptyline(line)
    return line == '' or sfind(line, '^%s*$') or sfind(line, '^%s*#')
end

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

local function checkdupekey(map, key)
    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
    return key
end

local function parseflowstyle(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)
                key.v = checkdupekey(stack[#stack].v, key.v)
                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 = parsestring(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 parseblockstylestring(line, lines, indent)
    if #lines == 0 then
        error("failed to find multi-line scalar content")
    end
    local s = {}
    local firstindent = -1
    local endline = -1
    for i = 1, #lines do
        local ln = lines[i]
        local idt = countindent(ln)
        if idt <= indent then
            break
        end
        if ln == '' then
            tinsert(s, '')
        else
            if firstindent == -1 then
                firstindent = idt
            elseif idt < firstindent then
                break
            end
            tinsert(s, ssub(ln, firstindent + 1))
        end
        endline = i
    end

    local striptrailing = true
    local sep = '\n'
    local newlineatend = true
    if line == '|' then
        striptrailing = true
        sep = '\n'
        newlineatend = true
    elseif line == '|+' then
        striptrailing = false
        sep = '\n'
        newlineatend = true
    elseif line == '|-' then
        striptrailing = true
        sep = '\n'
        newlineatend = false
    elseif line == '>' then
        striptrailing = true
        sep = ' '
        newlineatend = true
    elseif line == '>+' then
        striptrailing = false
        sep = ' '
        newlineatend = true
    elseif line == '>-' then
        striptrailing = true
        sep = ' '
        newlineatend = false
    else
        error('invalid blockstyle string:' .. line)
    end
    local eonl = 0
    for i = #s, 1, -1 do
        if s[i] == '' then
            tremove(s, i)
            eonl = eonl + 1
        end
    end
    if striptrailing then
        eonl = 0
    end
    if newlineatend then
        eonl = eonl + 1
    end
    for i = endline, 1, -1 do
        tremove(lines, i)
    end
    return table.concat(s, sep) .. string.rep('\n', eonl)
end

local function parsetimestamp(line)
    local _, p1, y, m, d = sfind(line, '^(%d%d%d%d)%-(%d%d)%-(%d%d)')
    if not p1 then
        return nil, line
    end
    if p1 == #line then
        return types.timestamp(y, m, d), ''
    end
    local _, p2, h, i, s = sfind(line, '^[Tt ](%d+):(%d+):(%d+)', p1 + 1)
    if not p2 then
        return types.timestamp(y, m, d), ssub(line, p1 + 1)
    end
    if p2 == #line then
        return types.timestamp(y, m, d, h, i, s), ''
    end
    local _, p3, f = sfind(line, '^%.(%d+)', p2 + 1)
    if not p3 then
        p3 = p2
        f = 0
    end
    local zc = ssub(line, p3 + 1, p3 + 1)
    local _, p4, zs, z = sfind(line, '^ ?([%+%-])(%d+)', p3 + 1)
    if p4 then
        z = tonumber(z)
        local _, p5, zi = sfind(line, '^:(%d+)', p4 + 1)
        if p5 then
            z = z + tonumber(zi) / 60
        end
        z = zs == '-' and -tonumber(z) or tonumber(z)
    elseif zc == 'Z' then
        p4 = p3 + 1
        z = 0
    else
        p4 = p3
        z = false
    end
    return types.timestamp(y, m, d, h, i, s, f, z), ssub(line, p4 + 1)
end

local function parsescalar(line, lines, indent)
    line = ltrim(line)
    line = gsub(line, '^%s*#.*$', '') -- comment only -> ''
    line = gsub(line, '^%s*', '') -- trim head spaces

    if line == '' or line == '~' then
        return null
    end

    local ts, _ = parsetimestamp(line)
    if ts then
        return ts
    end

    local s, _ = parsestring(line)
    -- startswith quote ... string
    -- not startswith quote ... maybe string
    if s and (startswith(line, '"') or startswith(line, "'")) then
        return s
    end

    if startswith('!', line) then -- unexpected tagchar
        error('unsupported line: ' .. line)
    end

    if equalsline(line, '{}') then
        return {}
    end
    if equalsline(line, '[]') then
        return {}
    end

    if startswith(line, '{') or startswith(line, '[') then
        return parseflowstyle(line, lines)
    end

    if startswith(line, '|') or startswith(line, '>') then
        return parseblockstylestring(line, lines, indent)
    end

    -- Regular unquoted string
    line = gsub(line, '%s*#.*$', '') -- trim tail comment
    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) -- : int
    elseif sfind(v, '^[%+%-]?[0-9]+%.[0-9]+$') then
        return tonumber(v)
    end
    return s or v
end

local parsemap; -- : func

local function parseseq(line, lines, indent)
    local seq = setmetatable({}, types.seq)
    if line ~= '' then
        error()
    end
    while #lines > 0 do
        -- Check for a new document
        line = lines[1]
        if startswith(line, '---') then
            while #lines > 0 and not startswith(lines, '---') do
                tremove(lines, 1)
            end
            return seq
        end

        -- Check the indent level
        local level = countindent(line)
        if level < indent then
            return seq
        elseif level > indent 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
            -- Inline nested hash
            local indent2 = j
            lines[1] = string.rep(' ', indent2) .. rest
            tinsert(seq, parsemap('', lines, indent2))
        elseif sfind(rest, '^%-%s+') then
            -- Inline nested seq
            local indent2 = j
            lines[1] = string.rep(' ', indent2) .. rest
            tinsert(seq, parseseq('', 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 = countindent(nextline)
                if indent2 == indent then
                    -- Null seqay entry
                    tinsert(seq, null)
                else
                    tinsert(seq, parseseq('', lines, indent2))
                end
            else
                -- - # comment
                --   key: value
                local nextline = lines[1]
                local indent2 = countindent(nextline)
                tinsert(seq, parsemap('', lines, indent2))
            end
        elseif rest then
            -- Array entry with a value
            tremove(lines, 1)
            tinsert(seq, parsescalar(rest, lines))
        end
    end
    return seq
end

local function parseset(line, lines, indent)
    if not isemptyline(line) then
        error('not seq line: ' .. line)
    end
    local set = setmetatable({}, types.set)
    while #lines > 0 do
        -- Check for a new document
        line = lines[1]
        if startswith(line, '---') then
            while #lines > 0 and not startswith(lines, '---') do
                tremove(lines, 1)
            end
            return set
        end

        -- Check the indent level
        local level = countindent(line)
        if level < indent then
            return set
        elseif level > indent 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 set
            end
        end
        local rest = ssub(line, j + 1)

        if sfind(rest, '^[^\'\"%s]*:') then
            -- Inline nested hash
            local indent2 = j
            lines[1] = string.rep(' ', indent2) .. rest
            set[parsemap('', lines, indent2)] = true
        elseif sfind(rest, '^%s+$') then
            tremove(lines, 1)
            if #lines == 0 then
                tinsert(set, null)
                return set
            end
            if sfind(lines[1], '^%s*%?') then
                local indent2 = countindent(lines[1])
                if indent2 == indent then
                    -- Null array entry
                    set[null] = true
                else
                    set[parseseq('', lines, indent2)] = true
                end
            end

        elseif rest then
            tremove(lines, 1)
            set[parsescalar(rest, lines)] = true
        else
            error("failed to classify line: " .. line)
        end
    end
    return set
end

function parsemap(line, lines, indent)
    if not isemptyline(line) then
        error('not map line: ' .. line)
    end
    local map = setmetatable({}, types.map)
    while #lines > 0 do
        -- Check for a new document
        line = lines[1]
        if startswith(line, '---') then
            while #lines > 0 and not startswith(lines, '---') do
                tremove(lines, 1)
            end
            return map
        end

        -- Check the indent level
        local level, _ = countindent(line)
        if level < indent then
            return map
        elseif level > indent then
            error("found bad indenting in line: " .. line)
        end

        -- Find the key
        local key
        local s, rest = parsestring(line)

        -- Quoted keys
        if s and startswith(rest, ':') then
            local sc = parsescalar(s, {}, 0)
            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

        key = checkdupekey(map, key)
        line = ltrim(line)

        if ssub(line, 1, 1) == '!' then
            -- ignore type
            local rh = ltrim(ssub(line, 3))
            local typename = smatch(rh, '^!?[^%s]+')
            line = ltrim(ssub(rh, #typename + 1))
        end

        if not isemptyline(line) then
            tremove(lines, 1)
            line = ltrim(line)
            map[key] = parsescalar(line, lines, indent)
        else
            -- An indent
            tremove(lines, 1)
            if #lines == 0 then
                map[key] = null
                return map;
            end
            if sfind(lines[1], '^%s*%-') then
                local indent2 = countindent(lines[1])
                map[key] = parseseq('', lines, indent2)
            elseif sfind(lines[1], '^%s*%?') then
                local indent2 = countindent(lines[1])
                map[key] = parseset('', lines, indent2)
            else
                local indent2 = countindent(lines[1])
                if indent >= indent2 then
                    -- Null hash entry
                    map[key] = null
                else
                    map[key] = parsemap('', lines, indent2)
                end
            end
        end
    end
    return map
end

-- : (list<str>)->dict
local function parsedocuments(lines)
    lines = select(lines, function(s)
        return not isemptyline(s)
    end)

    if sfind(lines[1], '^%%YAML') then
        tremove(lines, 1)
    end

    local root = {}
    local in_document = false
    while #lines > 0 do
        local line = lines[1]
        -- Do we have a document header?
        local docright;
        if sfind(line, '^%-%-%-') then
            -- Handle scalar documents
            docright = ssub(line, 4)
            tremove(lines, 1)
            in_document = true
        end
        if docright then
            if (not sfind(docright, '^%s+$') and not sfind(docright, '^%s+#')) then
                tinsert(root, parsescalar(docright, lines))
            end
        elseif #lines == 0 or startswith(line, '---') then
            -- A naked document
            tinsert(root, null)
            while #lines > 0 and not sfind(lines[1], '---') do
                tremove(lines, 1)
            end
            in_document = false
            -- XXX The final '-+$' is to look for -- which ends up being an
            -- error later.
        elseif not in_document and #root > 0 then
            -- only the first document can be explicit
            error('parse error: ' .. line)
        elseif sfind(line, '^%s*%-') then
            -- An array at the root
            tinsert(root, parseseq('', lines, 0))
        elseif sfind(line, '^%s*[^%s]') then
            -- A hash at the root
            local level = countindent(line)
            tinsert(root, parsemap('', lines, level))
        else
            -- Shouldn't get here.  @lines have whitespace-only lines
            -- stripped, and previous match is a line with any
            -- non-whitespace.  So this clause should only be reachable via
            -- a perlbug where \s is not symmetric with \S

            -- uncoverable statement
            error('parse error: ' .. line)
        end
    end
    if #root > 1 and Null.isnull(root[1]) then
        tremove(root, 1)
        return root
    end
    return root
end

--- Parse yaml string into table.
local function parse(source)
    local lines = {}
    for line in string.gmatch(source .. '\n', '(.-)\r?\n') do
        tinsert(lines, line)
    end

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

    return docs
end

return {
    version = 0.1,
    parse = parse
}
