-- Copyright (c) 2017 Jonathan Stoler
-- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--
-- converts TOML data into a lua table
function parse(toml, options)
    options = options or {}
    local strict = (options.strict ~= nil and options.strict)

    -- the official TOML definition of whitespace
    local ws = "[\009\032]"

    -- the official TOML definition of newline
    local nl = "[\10"
    do
        local crlf = "\13\10"
        nl = nl .. crlf
    end
    nl = nl .. "]"

    -- stores text data
    local buffer = ""

    -- the current location within the string to parse
    local cursor = 1

    -- the output table
    local out = {}

    -- the current table to write to
    local obj = out

    -- returns the next n characters from the current position
    local function char(n)
        n = n or 0
        return toml:sub(cursor + n, cursor + n)
    end

    -- moves the current position forward n (default: 1) characters
    local function step(n)
        n = n or 1
        cursor = cursor + n
    end

    -- move forward until the next non-whitespace character
    local function skipWhitespace()
        while (char():match(ws)) do
            step()
        end
    end

    -- remove the (Lua) whitespace at the beginning and end of a string
    local function trim(str)
        return str:gsub("^%s*(.-)%s*$", "%1")
    end

    -- divide a string into a table around a delimiter
    local function split(str, delim)
        if str == "" then
            return {}
        end
        local result = {}
        local append = delim
        if delim:match("%%") then
            append = delim:gsub("%%", "")
        end
        for match in (str .. append):gmatch("(.-)" .. delim) do
            table.insert(result, match)
        end
        return result
    end

    -- produce a parsing error message
    -- the error contains the line number of the current position
    local function err(message, strictOnly)
        if not strictOnly or (strictOnly and strict) then
            local line = 1
            local c = 0
            for l in toml:gmatch("(.-)" .. nl) do
                c = c + l:len()
                if c >= cursor then
                    break
                end
                line = line + 1
            end
            error("TOML: " .. message .. " on line " .. line .. ".", 4)
        end
    end

    -- prevent infinite loops by checking whether the cursor is
    -- at the end of the document or not
    local function bounds()
        return cursor <= toml:len()
    end

    local function parseString()
        local quoteType = char() -- should be single or double quote

        -- this is a multiline string if the next 2 characters match
        local multiline = (char(1) == char(2) and char(1) == char())

        -- buffer to hold the string
        local str = ""

        -- skip the quotes
        step(multiline and 3 or 1)

        while (bounds()) do
            if multiline and char():match(nl) and str == "" then
                -- skip line break line at the beginning of multiline string
                step()
            end

            -- keep going until we encounter the quote character again
            if char() == quoteType then
                if multiline then
                    if char(1) == char(2) and char(1) == quoteType then
                        step(3)
                        break
                    end
                else
                    step()
                    break
                end
            end

            if char():match(nl) and not multiline then
                err("Single-line string cannot contain line break")
            end

            -- if we're in a double-quoted string, watch for escape characters!
            if quoteType == '"' and char() == "\\" then
                if multiline and char(1):match(nl) then
                    -- skip until first non-whitespace character
                    step(1) -- go past the line break
                    while (bounds()) do
                        if not char():match(ws) and not char():match(nl) then
                            break
                        end
                        step()
                    end
                else
                    -- all available escape characters
                    local escape = {
                        b = "\b",
                        t = "\t",
                        n = "\n",
                        f = "\f",
                        r = "\r",
                        ['"'] = '"',
                        ["\\"] = "\\"
                    }
                    -- utf function from http://stackoverflow.com/a/26071044
                    -- converts \uXXX into actual unicode
                    local function utf(char)
                        local bytemarkers = {{0x7ff, 192}, {0xffff, 224}, {0x1fffff, 240}}
                        if char < 128 then
                            return string.char(char)
                        end
                        local charbytes = {}
                        for bytes, vals in pairs(bytemarkers) do
                            if char <= vals[1] then
                                for b = bytes + 1, 2, -1 do
                                    local mod = char % 64
                                    char = (char - mod) / 64
                                    charbytes[b] = string.char(128 + mod)
                                end
                                charbytes[1] = string.char(vals[2] + char)
                                break
                            end
                        end
                        return table.concat(charbytes)
                    end

                    if escape[char(1)] then
                        -- normal escape
                        str = str .. escape[char(1)]
                        step(2) -- go past backslash and the character
                    elseif char(1) == "u" then
                        -- utf-16
                        step()
                        local uni = char(1) .. char(2) .. char(3) .. char(4)
                        step(5)
                        uni = tonumber(uni, 16)
                        if (uni >= 0 and uni <= 0xd7ff) and not (uni >= 0xe000 and uni <= 0x10ffff) then
                            str = str .. utf(uni)
                        else
                            err("Unicode escape is not a Unicode scalar")
                        end
                    elseif char(1) == "U" then
                        -- utf-32
                        step()
                        local uni = char(1) .. char(2) .. char(3) .. char(4) .. char(5) .. char(6) .. char(7) .. char(8)
                        step(9)
                        uni = tonumber(uni, 16)
                        if (uni >= 0 and uni <= 0xd7ff) and not (uni >= 0xe000 and uni <= 0x10ffff) then
                            str = str .. utf(uni)
                        else
                            err("Unicode escape is not a Unicode scalar")
                        end
                    else
                        err("Invalid escape")
                    end
                end
            else
                -- if we're not in a double-quoted string, just append it to our buffer raw and keep going
                str = str .. char()
                step()
            end
        end

        return {
            value = str,
            type = "string"
        }
    end

    local function parseNumber()
        local num = ""
        local exp
        local date = false
        while (bounds()) do
            if char():match("[%+%-%.eE_0-9]") then
                if not exp then
                    if char():lower() == "e" then
                        -- as soon as we reach e or E, start appending to exponent buffer instead of
                        -- number buffer
                        exp = ""
                    elseif char() ~= "_" then
                        num = num .. char()
                    end
                elseif char():match("[%+%-0-9]") then
                    exp = exp .. char()
                else
                    err("Invalid exponent")
                end
            elseif char():match(ws) or char() == "#" or char():match(nl) or char() == "," or char() == "]" or char() ==
                "}" then
                break
            elseif char() == "T" or char() == "Z" then
                -- parse the date (as a string, since lua has no date object)
                date = true
                while (bounds()) do
                    if char() == "," or char() == "]" or char() == "#" or char():match(nl) or char():match(ws) then
                        break
                    end
                    num = num .. char()
                    step()
                end
            else
                err("Invalid number")
            end
            step()
        end

        if date then
            return {
                value = num,
                type = "date"
            }
        end

        local float = false
        if num:match("%.") then
            float = true
        end

        exp = exp and tonumber(exp) or 0
        num = tonumber(num)

        if not float then
            return {
                -- lua will automatically convert the result
                -- of a power operation to a float, so we have
                -- to convert it back to an int with math.floor
                value = math.floor(num * 10 ^ exp),
                type = "int"
            }
        end

        return {
            value = num * 10 ^ exp,
            type = "float"
        }
    end

    local parseArray, getValue

    function parseArray()
        step() -- skip [
        skipWhitespace()

        local arrayType
        local array = {}

        while (bounds()) do
            if char() == "]" then
                break
            elseif char():match(nl) then
                -- skip
                step()
                skipWhitespace()
            elseif char() == "#" then
                while (bounds() and not char():match(nl)) do
                    step()
                end
            else
                -- get the next object in the array
                local v = getValue()
                if not v then
                    break
                end

                -- set the type if it hasn't been set before
                if arrayType == nil then
                    arrayType = v.type
                elseif arrayType ~= v.type then
                    err("Mixed types in array", true)
                end

                array = array or {}
                table.insert(array, v.value)

                if char() == "," then
                    step()
                end
                skipWhitespace()
            end
        end
        step()

        return {
            value = array,
            type = "array"
        }
    end

    local function parseInlineTable()
        step() -- skip opening brace

        local buffer = ""
        local quoted = false
        local tbl = {}

        while bounds() do
            if char() == "}" then
                break
            elseif char() == "'" or char() == '"' then
                buffer = parseString().value
                quoted = true
            elseif char() == "=" then
                if not quoted then
                    buffer = trim(buffer)
                end

                step() -- skip =
                skipWhitespace()

                if char():match(nl) then
                    err("Newline in inline table")
                end

                local v = getValue().value
                tbl[buffer] = v

                skipWhitespace()

                if char() == "," then
                    step()
                elseif char():match(nl) then
                    err("Newline in inline table")
                end

                quoted = false
                buffer = ""
            else
                buffer = buffer .. char()
                step()
            end
        end
        step() -- skip closing brace

        return {
            value = tbl,
            type = "array"
        }
    end

    local function parseBoolean()
        local v
        if toml:sub(cursor, cursor + 3) == "true" then
            step(4)
            v = {
                value = true,
                type = "boolean"
            }
        elseif toml:sub(cursor, cursor + 4) == "false" then
            step(5)
            v = {
                value = false,
                type = "boolean"
            }
        else
            err("Invalid primitive")
        end

        skipWhitespace()
        if char() == "#" then
            while (not char():match(nl)) do
                step()
            end
        end

        return v
    end

    -- figure out the type and get the next value in the document
    function getValue()
        if char() == '"' or char() == "'" then
            return parseString()
        elseif char():match("[%+%-0-9]") then
            return parseNumber()
        elseif char() == "[" then
            return parseArray()
        elseif char() == "{" then
            return parseInlineTable()
        else
            return parseBoolean()
        end
        -- date regex (for possible future support):
        -- %d%d%d%d%-[0-1][0-9]%-[0-3][0-9]T[0-2][0-9]%:[0-6][0-9]%:[0-6][0-9][Z%:%+%-%.0-9]*
    end

    -- track whether the current key was quoted or not
    local quotedKey = false

    -- parse the document!
    while (cursor <= toml:len()) do

        -- skip comments and whitespace
        if char() == "#" then
            while (not char():match(nl)) do
                step()
            end
        end

        if char():match(nl) then
            -- skip
        end

        if char() == "=" then
            step()
            skipWhitespace()

            -- trim key name
            buffer = trim(buffer)

            if buffer:match("^[0-9]*$") and not quotedKey then
                buffer = tonumber(buffer)
            end

            if buffer == "" and not quotedKey then
                err("Empty key name")
            end

            local v = getValue()
            if v then
                -- if the key already exists in the current object, throw an error
                if obj[buffer] then
                    err('Cannot redefine key "' .. buffer .. '"', true)
                end
                obj[buffer] = v.value
            end

            -- clear the buffer
            buffer = ""
            quotedKey = false

            -- skip whitespace and comments
            skipWhitespace()
            if char() == "#" then
                while (bounds() and not char():match(nl)) do
                    step()
                end
            end

            -- if there is anything left on this line after parsing a key and its value,
            -- throw an error
            if not char():match(nl) and cursor < toml:len() then
                err("Invalid primitive")
            end
        elseif char() == "[" then
            buffer = ""
            step()
            local tableArray = false

            -- if there are two brackets in a row, it's a table array!
            if char() == "[" then
                tableArray = true
                step()
            end

            obj = out

            local function processKey(isLast)
                isLast = isLast or false
                buffer = trim(buffer)

                if not quotedKey and buffer == "" then
                    err("Empty table name")
                end

                if isLast and obj[buffer] and not tableArray and #obj[buffer] > 0 then
                    err("Cannot redefine table", true)
                end

                -- set obj to the appropriate table so we can start
                -- filling it with values!
                if tableArray then
                    -- push onto cache
                    if obj[buffer] then
                        obj = obj[buffer]
                        if isLast then
                            table.insert(obj, {})
                        end
                        obj = obj[#obj]
                    else
                        obj[buffer] = {}
                        obj = obj[buffer]
                        if isLast then
                            table.insert(obj, {})
                            obj = obj[1]
                        end
                    end
                else
                    obj[buffer] = obj[buffer] or {}
                    obj = obj[buffer]
                end
            end

            while (bounds()) do
                if char() == "]" then
                    if tableArray then
                        if char(1) ~= "]" then
                            err("Mismatching brackets")
                        else
                            step() -- skip inside bracket
                        end
                    end
                    step() -- skip outside bracket

                    processKey(true)
                    buffer = ""
                    break
                elseif char() == '"' or char() == "'" then
                    buffer = parseString().value
                    quotedKey = true
                elseif char() == "." then
                    step() -- skip period
                    processKey()
                    buffer = ""
                else
                    buffer = buffer .. char()
                    step()
                end
            end

            buffer = ""
            quotedKey = false
        elseif (char() == '"' or char() == "'") then
            -- quoted key
            buffer = parseString().value
            quotedKey = true
        end

        buffer = buffer .. (char():match(nl) and "" or char())
        step()
    end

    return out
end

function encode(tbl)
    local toml = ""

    local cache = {}

    local function parse(tbl)
        for k, v in pairs(tbl) do
            if type(v) == "boolean" then
                toml = toml .. k .. " = " .. tostring(v) .. "\n"
            elseif type(v) == "number" then
                toml = toml .. k .. " = " .. tostring(v) .. "\n"
            elseif type(v) == "string" then
                local quote = '"'
                v = v:gsub("\\", "\\\\")

                -- if the string has any line breaks, make it multiline
                if v:match("^\n(.*)$") then
                    quote = quote:rep(3)
                    v = "\\n" .. v
                elseif v:match("\n") then
                    quote = quote:rep(3)
                end

                v = v:gsub("\b", "\\b")
                v = v:gsub("\t", "\\t")
                v = v:gsub("\f", "\\f")
                v = v:gsub("\r", "\\r")
                v = v:gsub('"', '\\"')
                v = v:gsub("/", "\\/")
                toml = toml .. k .. " = " .. quote .. v .. quote .. "\n"
            elseif type(v) == "table" then
                local array, arrayTable = true, true
                local first = {}
                for kk, vv in pairs(v) do
                    if type(kk) ~= "number" then
                        array = false
                    end
                    if type(vv) ~= "table" then
                        v[kk] = nil
                        first[kk] = vv
                        arrayTable = false
                    end
                end

                if array then
                    if arrayTable then
                        -- double bracket syntax go!
                        table.insert(cache, k)
                        for kk, vv in pairs(v) do
                            toml = toml .. "[[" .. table.concat(cache, ".") .. "]]\n"
                            for k3, v3 in pairs(vv) do
                                if type(v3) ~= "table" then
                                    vv[k3] = nil
                                    first[k3] = v3
                                end
                            end
                            parse(first)
                            parse(vv)
                        end
                        table.remove(cache)
                    else
                        -- plain ol boring array
                        toml = toml .. k .. " = [\n"
                        for kk, vv in pairs(first) do
                            toml = toml .. tostring(vv) .. ",\n"
                        end
                        toml = toml .. "]\n"
                    end
                else
                    -- just a key/value table, folks
                    table.insert(cache, k)
                    toml = toml .. "[" .. table.concat(cache, ".") .. "]\n"
                    parse(first)
                    parse(v)
                    table.remove(cache)
                end
            end
        end
    end

    parse(tbl)

    return toml:sub(1, -2)
end

return {parse, encode}
