--[[
  (The MIT License)

  Copyright (c) 2017 Dominic Letz dominicletz@exosite.com

  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.
--]]

local table_print_value
table_print_value = function(value, indent, done)
  indent = indent or 0
  done = done or {}
  if istable(value) and not done [value] then
    done [value] = true

    local list = {}
    for key in pairs (value) do
      list[#list + 1] = key
    end
    table.sort(list, function(a, b) return tostring(a) < tostring(b) end)
    local last = list[#list]

    local rep = "{\n"
    local comma
    for _, key in ipairs (list) do
      if key == last then
        comma = ''
      else
        comma = ','
      end
      local keyRep
      if isnumber(key) then
        keyRep = key
      else
        keyRep = string.format("%q", tostring(key))
      end
      rep = rep .. string.format(
        "%s[%s] = %s%s\n",
        string.rep(" ", indent + 2),
        keyRep,
        table_print_value(value[key], indent + 2, done),
        comma
      )
    end

    rep = rep .. string.rep(" ", indent) -- indent it
    rep = rep .. "}"

    done[value] = false
    return rep
  elseif isstring(value) then
    return string.format("%q", value)
  else
    return tostring(value)
  end
end

local table_print = function(tt)
  print('return '..table_print_value(tt))
end

local table_clone = function(t)
  local clone = {}
  for k,v in pairs(t) do
    clone[k] = v
  end
  return clone
end

local string_trim = function(s, what)
  what = what or " "
  return s:gsub("^[" .. what .. "]*(.-)["..what.."]*$", "%1")
end

local push = function(stack, item)
  stack[#stack + 1] = item
end

local pop = function(stack)
  local item = stack[#stack]
  stack[#stack] = nil
  return item
end

local context = function (str)
  if not isstring(str) then
    return ""
  end

  str = str:sub(0,25):gsub("\n","\\n"):gsub("\"","\\\"");
  return ", near \"" .. str .. "\""
end

local Parser = {}
function Parser.new (self, tokens)
  self.tokens = tokens
  self.parse_stack = {}
  self.refs = {}
  self.current = 0
  return self
end

local exports = {version = "1.2"}

local word = function(w) return "^("..w..")([%s$%c])" end

local tokens = {
  {"comment",   "^#[^\n]*"},
  {"indent",    "^\n( *)"},
  {"space",     "^ +"},
  {"true",      word("enabled"),  const = true, value = true},
  {"true",      word("true"),     const = true, value = true},
  {"true",      word("yes"),      const = true, value = true},
  {"true",      word("on"),      const = true, value = true},
  {"false",     word("disabled"), const = true, value = false},
  {"false",     word("false"),    const = true, value = false},
  {"false",     word("no"),       const = true, value = false},
  {"false",     word("off"),      const = true, value = false},
  {"null",      word("null"),     const = true, value = nil},
  {"null",      word("Null"),     const = true, value = nil},
  {"null",      word("NULL"),     const = true, value = nil},
  {"null",      word("~"),        const = true, value = nil},
  {"id",    "^\"([^\"]-)\" *(:[%s%c])"},
  {"id",    "^'([^']-)' *(:[%s%c])"},
  {"string",    "^\"([^\"]-)\"",  force_text = true},
  {"string",    "^'([^']-)'",    force_text = true},
  {"timestamp", "^(%d%d%d%d)-(%d%d?)-(%d%d?)%s+(%d%d?):(%d%d):(%d%d)%s+(%-?%d%d?):(%d%d)"},
  {"timestamp", "^(%d%d%d%d)-(%d%d?)-(%d%d?)%s+(%d%d?):(%d%d):(%d%d)%s+(%-?%d%d?)"},
  {"timestamp", "^(%d%d%d%d)-(%d%d?)-(%d%d?)%s+(%d%d?):(%d%d):(%d%d)"},
  {"timestamp", "^(%d%d%d%d)-(%d%d?)-(%d%d?)%s+(%d%d?):(%d%d)"},
  {"timestamp", "^(%d%d%d%d)-(%d%d?)-(%d%d?)%s+(%d%d?)"},
  {"timestamp", "^(%d%d%d%d)-(%d%d?)-(%d%d?)"},
  {"doc",       "^%-%-%-[^%c]*"},
  {",",         "^,"},
  {"string",    "^%b{} *[^,%c]+", noinline = true},
  {"{",         "^{"},
  {"}",         "^}"},
  {"string",    "^%b[] *[^,%c]+", noinline = true},
  {"[",         "^%["},
  {"]",         "^%]"},
  {"-",         "^%-"},
  {":",         "^:"},
  {"pipe",      "^(|)(%d*[+%-]?)", sep = "\n"},
  {"pipe",      "^(>)(%d*[+%-]?)", sep = " "},
  {"id",        "^([%w][%w %-_]*)(:[%s%c])"},
  {"string",    "^[^%c]+", noinline = true},
  {"string",    "^[^,%c ]+"}
};
exports.tokenize = function (str)
  local token
  local row = 0
  local ignore
  local indents = 0
  local lastIndents
  local stack = {}
  local indentAmount = 0
  local inline = false
  str = str:gsub("\r\n","\010")

  while #str > 0 do
    for i in ipairs(tokens) do
      local captures = {}
      if not inline or tokens[i].noinline == nil then
        captures = {str:match(tokens[i][2])}
      end

      if #captures > 0 then
        captures.input = str:sub(0, 25)
        token = table_clone(tokens[i])
        token[2] = captures
        local str2 = str:gsub(tokens[i][2], "", 1)
        token.raw = str:sub(1, #str - #str2)
        str = str2

        if token[1] == "{" or token[1] == "[" then
          inline = true
        elseif token.const then
          -- Since word pattern contains last char we're re-adding it
          str = token[2][2] .. str
          token.raw = token.raw:sub(1, #token.raw - #token[2][2])
        elseif token[1] == "id" then
          -- Since id pattern contains last semi-colon we're re-adding it
          str = token[2][2] .. str
          token.raw = token.raw:sub(1, #token.raw - #token[2][2])
          -- Trim
          token[2][1] = string_trim(token[2][1])
        elseif token[1] == "string" then
          -- Finding numbers
          local snip = token[2][1]
          if not token.force_text then
            if snip:match("^(%d+%.%d+)$") or snip:match("^(%d+)$") then
              token[1] = "number"
            end
          end

        elseif token[1] == "comment" then
          ignore = true;
        elseif token[1] == "indent" then
          row = row + 1
          inline = false
          lastIndents = indents
          if indentAmount == 0 then
            indentAmount = #token[2][1]
          end

          if indentAmount ~= 0 then
            indents = (#token[2][1] / indentAmount);
          else
            indents = 0
          end

          if indents == lastIndents then
            ignore = true;
          elseif indents > lastIndents + 2 then
            error("SyntaxError: invalid indentation, got " .. tostring(indents)
              .. " instead of " .. tostring(lastIndents) .. context(token[2].input))
          elseif indents > lastIndents + 1 then
            push(stack, token)
          elseif indents < lastIndents then
            local input = token[2].input
            token = {"dedent", {"", input = ""}}
            token.input = input
            while lastIndents > indents + 1 do
              lastIndents = lastIndents - 1
              push(stack, token)
            end
          end
        end -- if token[1] == XXX
        token.row = row
        break
      end -- if #captures > 0
    end

    if not ignore then
      if token then
        push(stack, token)
        token = nil
      else
        error("SyntaxError " .. context(str))
      end
    end

    ignore = false;
  end

  return stack
end

Parser.peek = function (self, offset)
  offset = offset or 1
  return self.tokens[offset + self.current]
end

Parser.advance = function (self)
  self.current = self.current + 1
  return self.tokens[self.current]
end

Parser.advanceValue = function (self)
  return self:advance()[2][1]
end

Parser.accept = function (self, type)
  if self:peekType(type) then
    return self:advance()
  end
end

Parser.expect = function (self, type, msg)
  return self:accept(type) or
    error(msg .. context(self:peek()[1].input))
end

Parser.expectDedent = function (self, msg)
  return self:accept("dedent") or (self:peek() == nil) or
    error(msg .. context(self:peek()[2].input))
end

Parser.peekType = function (self, val, offset)
  return self:peek(offset) and self:peek(offset)[1] == val
end

Parser.ignore = function (self, items)
  local advanced
  repeat
    advanced = false
    for _,v in pairs(items) do
      if self:peekType(v) then
        self:advance()
        advanced = true
      end
    end
  until advanced == false
end

Parser.ignoreSpace = function (self)
  self:ignore{"space"}
end

Parser.ignoreWhitespace = function (self)
  self:ignore{"space", "indent", "dedent"}
end

Parser.parse = function (self)

  local ref = nil
  if self:peekType("string") and not self:peek().force_text then
    local char = self:peek()[2][1]:sub(1,1)
    if char == "&" then
      ref = self:peek()[2][1]:sub(2)
      self:advanceValue()
      self:ignoreSpace()
    elseif char == "*" then
      ref = self:peek()[2][1]:sub(2)
      return self.refs[ref]
    end
  end

  local result
  local c = {
    indent = self:accept("indent") and 1 or 0,
    token = self:peek()
  }
  push(self.parse_stack, c)

  if c.token[1] == "doc" then
    result = self:parseDoc()
  elseif c.token[1] == "-" then
    result = self:parseList()
  elseif c.token[1] == "{" then
    result = self:parseInlineHash()
  elseif c.token[1] == "[" then
    result = self:parseInlineList()
  elseif c.token[1] == "id" then
    result = self:parseHash()
  elseif c.token[1] == "string" then
    result = self:parseString("\n")
  elseif c.token[1] == "timestamp" then
    result = self:parseTimestamp()
  elseif c.token[1] == "number" then
    result = tonumber(self:advanceValue())
  elseif c.token[1] == "pipe" then
    result = self:parsePipe()
  elseif c.token.const == true then
    self:advanceValue();
    result = c.token.value
  else
    error("ParseError: unexpected token '" .. c.token[1] .. "'" .. context(c.token.input))
  end

  pop(self.parse_stack)
  while c.indent > 0 do
    c.indent = c.indent - 1
    local term = "term "..c.token[1]..": '"..c.token[2][1].."'"
    self:expectDedent("last ".. term .." is not properly dedented")
  end

  if ref then
    self.refs[ref] = result
  end
  return result
end

Parser.parseDoc = function (self)
  self:accept("doc")
  return self:parse()
end

Parser.inline = function (self)
  local current = self:peek(0)
  if not current then
    return {}, 0
  end

  local inline = {}
  local i = 0

  while self:peek(i) and not self:peekType("indent", i) and current.row == self:peek(i).row do
    inline[self:peek(i)[1]] = true
    i = i - 1
  end
  return inline, -i
end

Parser.isInline = function (self)
  local _, i = self:inline()
  return i > 0
end

Parser.parent = function(self, level)
  level = level or 1
  return self.parse_stack[#self.parse_stack - level]
end

Parser.parentType = function(self, type, level)
  return self:parent(level) and self:parent(level).token[1] == type
end

Parser.parseString = function (self)
  if self:isInline() then
    local result = self:advanceValue()

    --[[
      - a: this looks
        flowing: but is
        no: string
    --]]
    local types = self:inline()
    if types["id"] and types["-"] then
      if not self:peekType("indent") or not self:peekType("indent", 2) then
        return result
      end
    end

    --[[
      a: 1
      b: this is
        a flowing string
        example
      c: 3
    --]]
    if self:peekType("indent") then
      self:expect("indent", "text block needs to start with indent")
      local addtl = self:accept("indent")

      result = result .. "\n" .. self:parseTextBlock("\n")

      self:expectDedent("text block ending dedent missing")
      if addtl then
        self:expectDedent("text block ending dedent missing")
      end
    end
    return result
  else
    --[[
      a: 1
      b:
        this is also
        a flowing string
        example
      c: 3
    --]]
    return self:parseTextBlock("\n")
  end
end

Parser.parsePipe = function (self)
  local pipe = self:expect("pipe")
  self:expect("indent", "text block needs to start with indent")
  local result = self:parseTextBlock(pipe.sep)
  self:expectDedent("text block ending dedent missing")
  return result
end

Parser.parseTextBlock = function (self, sep)
  local token = self:advance()
  local result = string_trim(token.raw, "\n")
  local indents = 0
  while self:peek() ~= nil and ( indents > 0 or not self:peekType("dedent") ) do
    local newtoken = self:advance()
    while token.row < newtoken.row do
      result = result .. sep
      token.row = token.row + 1
    end
    if newtoken[1] == "indent" then
      indents = indents + 1
    elseif newtoken[1] == "dedent" then
      indents = indents - 1
    else
      result = result .. string_trim(newtoken.raw, "\n")
    end
  end
  return result
end

Parser.parseHash = function (self, hash)
  hash = hash or {}
  local indents = 0

  if self:isInline() then
    local id = self:advanceValue()
    self:expect(":", "expected semi-colon after id")
    self:ignoreSpace()
    if self:accept("indent") then
      indents = indents + 1
      hash[id] = self:parse()
    else
      hash[id] = self:parse()
      if self:accept("indent") then
        indents = indents + 1
      end
    end
    self:ignoreSpace();
  end

  while self:peekType("id") do
    local id = self:advanceValue()
    self:expect(":","expected semi-colon after id")
    self:ignoreSpace()
    hash[id] = self:parse()
    self:ignoreSpace();
  end

  while indents > 0 do
    self:expectDedent("expected dedent")
    indents = indents - 1
  end

  return hash
end

Parser.parseInlineHash = function (self)
  local id
  local hash = {}
  local i = 0

  self:accept("{")
  while not self:accept("}") do
    self:ignoreSpace()
    if i > 0 then
      self:expect(",","expected comma")
    end

    self:ignoreWhitespace()
    if self:peekType("id") then
      id = self:advanceValue()
      if id then
        self:expect(":","expected semi-colon after id")
        self:ignoreSpace()
        hash[id] = self:parse()
        self:ignoreWhitespace()
      end
    end

    i = i + 1
  end
  return hash
end

Parser.parseList = function (self)
  local list = {}
  while self:accept("-") do
    self:ignoreSpace()
    list[#list + 1] = self:parse()

    self:ignoreSpace()
  end
  return list
end

Parser.parseInlineList = function (self)
  local list = {}
  local i = 0
  self:accept("[")
  while not self:accept("]") do
    self:ignoreSpace()
    if i > 0 then
      self:expect(",","expected comma")
    end

    self:ignoreSpace()
    list[#list + 1] = self:parse()
    self:ignoreSpace()
    i = i + 1
  end

  return list
end

Parser.parseTimestamp = function (self)
  local capture = self:advance()[2]

  return os.time{
    year  = capture[1],
    month = capture[2],
    day   = capture[3],
    hour  = capture[4] or 0,
    min   = capture[5] or 0,
    sec   = capture[6] or 0
  }
end

exports.Eval = function (str)
  return Parser:new(exports.tokenize(str)):parse()
end

exports.Read = function(file_name)
  if file.Exists(file_name, 'GAME') then
    local local_name = file_name:gsub('%.y([a]?)ml', '.local.y%1ml')

    if file.Exists(local_name, 'GAME') then
      file_name = local_name
    end

    return Parser:new(exports.tokenize(file.Read(file_name, 'GAME'))):parse()
  end
end

exports.Dump = table_print

ix.yaml = exports
