--
-- Copyright (c) 2021-2022 Zeping Lee
-- Released under the MIT license.
-- Repository: https://github.com/zepinglee/citeproc-lua
--

local output_module = {}

local unicode = require("unicode")
local dom = require("luaxml-domobject")

local util = require("citeproc-util")


local LocalizedQuotes = {
  outer_open = util.unicode['left double quotation mark'],
  outer_close = util.unicode['right double quotation mark'],
  inner_open = util.unicode['left single quotation mark'],
  inner_close = util.unicode['right single quotation mark'],
  punctuation_in_quote = false,
}

function LocalizedQuotes:new(outer_open, outer_close, inner_open, inner_close, punctuation_in_quote)
  local o = {
    outer_open = outer_open or util.unicode['left double quotation mark'],
    outer_close = outer_close or util.unicode['right double quotation mark'],
    inner_open = inner_open or util.unicode['left single quotation mark'],
    inner_close = inner_close or util.unicode['right single quotation mark'],
    punctuation_in_quote = punctuation_in_quote,
  }
  setmetatable(o, self)
  self.__index = self
  return o
end


local InlineElement = {
  _type = "InlineElement",
  _base_class = "InlineElement",
}

function InlineElement:derive(type)
  local o = {
    _type  = type,
  }
  self[type] = o
  setmetatable(o, self)
  self.__index = self
  o.__index = o
  return o
end

function InlineElement:new(inlines)
  local o = {
    inlines = inlines,
    _type  = self._type,
  }
  setmetatable(o, self)
  return o
end


local PlainText = InlineElement:derive("PlainText")

function PlainText:new(value)
  local o = InlineElement.new(self)
  o.value = value
  setmetatable(o, self)
  return o
end


local Formatted = InlineElement:derive("Formatted")

function Formatted:new(inlines, formatting)
  local o = InlineElement.new(self)
  o.inlines = inlines
  o.formatting = formatting
  setmetatable(o, self)
  return o
end


local Micro = InlineElement:derive("Micro")

-- This is how we can flip-flop only user-supplied styling.
-- Inside this is parsed micro html
function Micro:new(inlines)
  local o = InlineElement.new(self)
  o.inlines = inlines
  setmetatable(o, self)
  return o
end


local Quoted = InlineElement:derive("Quoted")

function Quoted:new(inlines, localized_quotes)
  local o = InlineElement.new(self)
  o.inlines = inlines
  o.is_inner = false
  if localized_quotes then
    o.quotes = localized_quotes
  else
    o.quotes = LocalizedQuotes:new()
  end

  setmetatable(o, self)
  return o
end


local NoCase = InlineElement:derive("NoCase")

function NoCase:new(inlines)
  local o = InlineElement.new(self)
  o.inlines = inlines
  setmetatable(o, self)
  return o
end


local NoDecor = InlineElement:derive("NoDecor")

function NoDecor:new(inlines)
  local o = InlineElement.new(self)
  o.inlines = inlines
  setmetatable(o, self)
  return o
end


local Linked = InlineElement:derive("Linked")

function Linked:new(value, href)
  local o = InlineElement.new(self)
  o.value = value
  o.href = href
  setmetatable(o, self)
  return o
end


local Div = InlineElement:derive("Div")

function Div:new(inlines, display)
  local o = InlineElement.new(self)
  o.inlines = inlines
  o.div = display
  setmetatable(o, self)
  return o
end


function InlineElement:parse(str, context)
  -- Return a list of inlines
  if type(str) ~= "string" then
    print(debug.traceback())
  end
  local html_str = "<div>" .. str .. "</div>"
  local ok, html = pcall(dom.parse, html_str)
  local inlines
  if ok then
    local div = html:get_path("div")[1]
    local el = InlineElement:from_node(div)
    inlines = el.inlines
  else
    local el = PlainText:new(str)
    inlines = {el}
  end

  inlines = InlineElement:parse_quotes(inlines, context)
  return inlines
end

function InlineElement:from_node(node)
  local tag_name = node:get_element_name()
  local inlines = {}

  for _, child in ipairs(node:get_children()) do
    local inline
    if child:is_text() then
      inline = PlainText:new(child:get_text())
    elseif child:is_element() then
      inline = InlineElement:from_node(child)
    end
    table.insert(inlines, inline)
  end

  if tag_name == "i" then
    return Formatted:new(inlines, {["font-style"] = "italic"})
  elseif tag_name == "b" then
    return Formatted:new(inlines, {["font-weight"] = "bold"})
  elseif tag_name == "sup" then
    return Formatted:new(inlines, {["vertical-align"] = "sup"})
  elseif tag_name == "sub" then
    return Formatted:new(inlines, {["vertical-align"] = "sub"})
  elseif tag_name == "sc" then
    return Formatted:new(inlines, {["font-variant"] = "small-caps"})
  elseif tag_name == "span" then
    local style = node:get_attribute("style")
    local class = node:get_attribute("class")
    if style == "font-variant:small-caps;" or style == "font-variant: small-caps;" then
      return Formatted:new(inlines, {["font-variant"] = "small-caps"})
    elseif class == "nocase" then
      return NoCase:new(inlines)
    elseif class == "nodecor" then
      return NoDecor:new(inlines)
    end
  end

  return InlineElement:new(inlines)
end

function InlineElement:parse_quotes(inlines, context)
  local quote_fragments = InlineElement:get_quote_fragments(inlines)
  -- util.debug(quote_fragments)

  local quote_stack = {}
  local text_stack = {{}}

  local localized_quotes = context:get_localized_quotes()

  for _, fragment in ipairs(quote_fragments) do
    local top_text_list = text_stack[#text_stack]

    if type(fragment) == "table" then
      if fragment.inlines then
        fragment.inlines = self:parse_quotes(fragment.inlines, context)
      end
      table.insert(top_text_list, fragment)

    elseif type(fragment) == "string" then
      local quote = fragment
      local top_quote = quote_stack[#quote_stack]

      if quote == "'" then
        if top_quote == "'" and #top_text_list > 0 then
          table.remove(quote_stack)
          local quoted = Quoted:new(top_text_list, localized_quotes)
          table.remove(text_stack)
          table.insert(text_stack[#text_stack], quoted)
        else
          table.insert(quote_stack, quote)
          table.insert(text_stack, {})
        end

      elseif quote == '"' then
        if top_quote == '"' then
          table.remove(quote_stack)
          local quoted = Quoted:new(top_text_list, localized_quotes)
          table.remove(text_stack)
          table.insert(text_stack[#text_stack], quoted)
        else
          table.insert(quote_stack, quote)
          table.insert(text_stack, {})
        end

      elseif quote == util.unicode["left single quotation mark"] or
             quote == util.unicode["left double quotation mark"] or
             quote == util.unicode["left-pointing double angle quotation mark"] then
        table.insert(quote_stack, quote)
        table.insert(text_stack, {})

      elseif (quote == util.unicode["right single quotation mark"] and
              top_quote == util.unicode["left single quotation mark"]) or
             (quote == util.unicode["right double quotation mark"] and
              top_quote == util.unicode["left double quotation mark"]) or
             (quote == util.unicode["right-pointing double angle quotation mark"] and
              top_quote == util.unicode["left-pointing double angle quotation mark"]) then
          table.remove(quote_stack)
          local quoted = Quoted:new(top_text_list, localized_quotes)
          table.remove(text_stack)
          table.insert(text_stack[#text_stack], quoted)

      else
        local last_inline = top_text_list[#top_text_list]
        if last_inline and last_inline._type == "PlainText" then
          last_inline.value = last_inline.value .. fragment
        else
          table.insert(top_text_list, PlainText:new(fragment))
        end
      end

    end
  end

  local elements = text_stack[1]
  if #text_stack > 1 then
    assert(#text_stack == #quote_stack + 1)
    for i, quote in ipairs(quote_stack) do
      if quote == "'" then
        quote = util.unicode["apostrophe"]
      end
      local last_inline = elements[#elements]
      if last_inline and last_inline._type == "PlainText" then
        last_inline.value = last_inline.value .. quote
      else
        table.insert(elements, PlainText:new(quote))
      end

      for _, inline in ipairs(text_stack[i + 1]) do
        if inline._type == "PlainText" then
          local last_inline = elements[#elements]
          if last_inline and last_inline._type == "PlainText" then
            last_inline.value = last_inline.value .. inline.value
          else
            table.insert(elements, inline)
          end
        else
          table.insert(elements, inline)
        end
      end
    end
  end

  return elements
end

local function merge_fragments_at(fragments, i)
  if type(fragments[i+1]) == "string" then
    fragments[i] = fragments[i] .. fragments[i+1]
    table.remove(fragments, i+1)
  end
  if type(fragments[i-1]) == "string" then
    fragments[i-1] = fragments[i-1] .. fragments[i]
    table.remove(fragments, i)
  end
end

-- Return a list of strings and InlineElement
function InlineElement:get_quote_fragments(inlines)
  local fragments = {}
  for _, inline in ipairs(inlines) do
    if inline._type == "PlainText" then
      local quote_tuples = {}
      for _, quote in ipairs({
        "'",
        '"',
        util.unicode["left single quotation mark"],
        util.unicode["right single quotation mark"],
        util.unicode["left double quotation mark"],
        util.unicode["right double quotation mark"],
        util.unicode["left-pointing double angle quotation mark"],
        util.unicode["right-pointing double angle quotation mark"],
      }) do
        string.gsub(inline.value, "()(" .. quote .. ")()", function (idx, qt, next_idx)
          table.insert(quote_tuples, {idx, qt, next_idx})
        end)
      end
      table.sort(quote_tuples, function (a, b)
        return a[1] < b[1]
      end)
      local start_idx = 1
      for _, quote_tuple in ipairs(quote_tuples) do
        local idx, qt, next_idx = table.unpack(quote_tuple)
        local fragment = string.sub(inline.value, start_idx, idx-1)
        if fragment ~= "" then
          table.insert(fragments, fragment)
        end
        table.insert(fragments, qt)
        start_idx = next_idx
      end
      -- Insert last fragment
      local fragment = string.sub(inline.value, start_idx, #inline.value)
      if fragment ~= "" then
        table.insert(fragments, fragment)
      end
    else
      table.insert(fragments, inline)
    end

    for i = #fragments, 1, -1 do
      local fragment = fragments[i]
      if fragment == "'" or fragment == '"' then
        local left, right
        if i > 1 then
          left = fragments[i - 1]
          if type(left) == "table" then
            left = left:get_right_most_string()
          end
        end
        if i < #fragments then
          right = fragments[i + 1]
          if type(right) == "table" then
            right = right:get_left_most_string()
          end
        end
        -- TODO: consider utf-8
        if left and right then
          if string.match(left, "%s$") and string.match(right, "^%s") then
            -- Orphan quote: bugreports_SingleQuote.txt
            if fragment == "'" then
              fragments[i] = util.unicode['apostrophe']
            end
            merge_fragments_at(fragments, i)
          end
          if not string.match(left, "[%s%p]$") and not string.match(right, "^[%s%p]") then
            if fragment == "'" then
              fragments[i] = util.unicode['apostrophe']
            end
            merge_fragments_at(fragments, i)
          end
        end
      end
    end
  end
  return fragments
end

function InlineElement:get_left_most_string()
  if self.value then
    return self.value
  elseif self.inlines then
    return self.inlines[1]:get_left_most_string()
  end
end

function InlineElement:get_right_most_string()
  if self.value then
    return self.value
  elseif self.inlines then
    return self.inlines[#self.inlines]:get_left_most_string()
  end
end

function InlineElement:capitalize_first_term()
  -- util.debug(self)
  if self._type == "PlainText" then
    self.value = util.capitalize(self.value)
  elseif self.inlines[1] then
    self.inlines[1]:capitalize_first_term()
  end
end


local OutputFormat = {}

function OutputFormat:new(format_name)
  local o = {
    name = format_name,
  }
  setmetatable(o, self)
  self.__index = self
  return o
end

function OutputFormat:flatten_ir(ir)
  if self.group_var == "missing" or self.collapse_suppressed then
    return {}
  end
  local inlines = {}
  if ir._type == "SeqIr" or ir._type == "NameIr" then
    inlines = self:flatten_seq_ir(ir)
  else
    inlines = self:with_format(ir.inlines, ir.formatting)
    inlines = self:affixed_quoted(inlines, ir.affixes, ir.quotes);
    inlines = self:with_display(inlines, ir.display);
  end
  return inlines
end

function OutputFormat:flatten_seq_ir(ir)
  if not ir.children then
    print(debug.traceback())
  end
  if #ir.children == 0 then
    return {}
  end
  local inlines_list = {}
  for _, child in ipairs(ir.children) do
    if child.group_var ~= "missing" and not child.collapse_suppressed then
      local child_inlines = self:flatten_ir(child)
      if #child_inlines > 0 then
        table.insert(inlines_list, child_inlines)
      end
    end
  end

  if #inlines_list == 0 then
    return {}
  end

  local inlines = self:group(inlines_list, ir.delimiter, ir.formatting)
  -- assert ir.quotes == localized quotes
  inlines = self:affixed_quoted(inlines, ir.affixes, ir.quotes);
  inlines = self:with_display(inlines, ir.display);
  return inlines
end

function OutputFormat:group(inlines_list, delimiter, formatting)
  -- Each node is list of InlineElements
  if #inlines_list == 1 then
    return self:format_list(inlines_list[1], formatting)
  else
    local inlines = OutputFormat:join_many(inlines_list, delimiter)
    return self:format_list(inlines, formatting)
  end
end

function OutputFormat:format_list(inlines, formatting)
  if formatting then
    return {Formatted:new(inlines, formatting)}
  else
    return inlines
  end
end

function OutputFormat:join_many(lists, delimiter)
  local res = {}
  for i, list in ipairs(lists) do
    if delimiter and i > 1 then
      table.insert(res, PlainText:new(delimiter))
    end
    for _, el in ipairs(list) do
      table.insert(res, el)
    end
  end
  return res
end

function OutputFormat:with_format(inlines, formatting)
  return self:format_list(inlines, formatting)
end

function OutputFormat:apply_text_case(inlines, text_case, is_english)
  if not inlines or #inlines == 0 or not text_case then
    return
  end
  -- Title case conversion only affects English-language items.
  if text_case == "title" and not is_english then
    return
  end
  local is_uppercase = false  -- TODO
  self:apply_text_case_inner(inlines, text_case, false, is_uppercase)
end

local function string_contains_word(str)
  return string.match(str, "%w") ~= nil
end

local function inline_contains_word(inline)
  if inline._type == "PlainText" then
    return string_contains_word(inline.value)
  elseif inline.inlines then
    for _, el in ipairs(inline.inlines) do
      if inline_contains_word(el) then
        return true
      end
    end
  end
  return false
end

function OutputFormat:apply_text_case_inner(inlines, text_case, seen_one, is_uppercase)
  for i, inline in ipairs(inlines) do
    if seen_one and text_case == "capitalize-first" then
      break
    end
    local is_last = (i == #inlines)
    if inline._type == "PlainText" then
      inline.value = self:transform_case(inline.value, text_case, seen_one, is_last, is_uppercase);
      seen_one = seen_one or string_contains_word(inline.value)
    elseif inline._type == "NoCase" or
           inline._type == "NoDecor" or
           (inline._type == "Formatted" and inline.formatting["font-variant"] == "small-caps") or
           (inline._type == "Formatted" and inline.formatting["vertical-align"] == "sup") or
           (inline._type == "Formatted" and inline.formatting["vertical-align"] == "sub") then
      seen_one = seen_one or inline_contains_word(inline)

    elseif inline._type == "Formatted" or inline._type == "Quoted" then
      seen_one = self:apply_text_case_inner(inline.inlines, text_case, seen_one, is_uppercase) or seen_one
    end
  end
  return seen_one
end

local function transform_lowercase(str)
  return string.gsub(str, utf8.charpattern, unicode.utf8.lower)
end

local function transform_uppercase(str)
  -- TODO: locale specific uppercase: textcase_LocaleUnicode.txt
  return string.gsub(str, utf8.charpattern, unicode.utf8.upper)
end

local function transform_first_word(str, transform)
  -- TODO: [Unicode word boundaries](https://www.unicode.org/reports/tr29/#Word_Boundaries)
  local segments = util.segment_words(str)
  for _, segment in ipairs(segments) do
    if segment[1] ~= "" then
      segment[1] = transform(segment[1])
      break
    end
  end
  local res = ""
  for _, segment in ipairs(segments) do
    res = res .. segment[1] .. segment[2]
  end
  return res
end

local function transform_each_word(str, seen_one, is_last, transform)
  -- util.debug(str)
  local segments = util.segment_words(str)
  -- util.debug(segments)
  -- print(debug.traceback())

  local first_idx
  local last_idx
  for i, segment in ipairs(segments) do
    if segment[1] ~= "" then
      if not first_idx then
        first_idx = i
      end
      last_idx = i
    end
  end

  local immediate_before = ""
  local last_punct = ""
  for i, segment in ipairs(segments) do
    local is_first_word = not seen_one and i == first_idx
    local is_last_word = is_last and i == last_idx
    local follows_colon = (
      last_punct == ":" or
      last_punct == "!" or
      last_punct == "?" or
      last_punct == "?")
    local no_stop_word = is_first_word or is_last_word or follows_colon or (segment[2] == "-" and immediate_before ~= "-")

    if (immediate_before == "." or immediate_before == "-") and #segment[1] == 1 then
    else
      segment[1] = transform(segment[1], no_stop_word)
    end

    if segment[1] ~= "" then
      immediate_before = segment[1]
      last_punct = string.match(segment[1], "(%S)%s*$") or last_punct
    end
    if segment[2] ~= "" then
      immediate_before = segment[2]
      last_punct = string.match(segment[2], "(%S)%s*$") or last_punct
    end
  end
  local res = ""
  for _, segment in ipairs(segments) do
    res = res .. segment[1] .. segment[2]
  end
  return res
end

local function transform_capitalize_word_if_lower(word)
  if util.is_lower(word) then
    return string.gsub(word, utf8.charpattern, unicode.utf8.upper, 1)
  else
    return word
  end
end

local function title_case_word(word, no_stop_word)
  -- Entirely non-English
  -- e.g. "β" in "β-Carotine"
  if string.match(word, "%a") and (not util.stop_words[word] or no_stop_word) and util.is_lower(word) then
    return string.gsub(word, utf8.charpattern, unicode.utf8.upper, 1)
  else
    return word
  end
end

function OutputFormat:transform_case(str, text_case, seen_one, is_last, is_uppercase)
  local res = str
  if text_case == "lowercase" then
    res = transform_lowercase(str)
  elseif text_case == "uppercase" then
    res = transform_uppercase(str)
  elseif text_case == "capitalize-first" then
    res = transform_first_word(str, transform_capitalize_word_if_lower)
  elseif text_case == "capitalize-all" then
    res = transform_each_word(str, false, false, transform_capitalize_word_if_lower)
  elseif text_case == "sentence" then
    -- TODO: if uppercase convert all to lowercase
    res = transform_first_word(str, transform_capitalize_word_if_lower)
  elseif text_case == "title" then
    -- TODO: if uppercase convert all to lowercase
    res = transform_each_word(str, seen_one, is_last, title_case_word)
  end
  return res
end

function OutputFormat:affixed(inlines, affixes)
  if affixes and affixes.prefix then
    table.insert(inlines, 1, PlainText:new(affixes.prefix))
  end
  if affixes and affixes.suffix then
    table.insert(inlines, PlainText:new(affixes.suffix))
  end
  return inlines
end

function OutputFormat:affixed_quoted(inlines, affixes, localized_quotes)
  inlines = util.clone(inlines)
  if localized_quotes then
    inlines = self:quoted(inlines, localized_quotes)
  end
  if affixes and affixes.prefix then
    table.insert(inlines, 1, PlainText:new(affixes.prefix))
  end
  if affixes and affixes.suffix then
    table.insert(inlines, PlainText:new(affixes.suffix))
  end
  return inlines
end

function OutputFormat:quoted(inlines, localized_quotes)
  return {Quoted:new(inlines, localized_quotes)}
end

function OutputFormat:with_display(nodes, display)
  if display then
    return {Div:new(nodes, display)}
  else
    return nodes
  end
end

function OutputFormat:output(inlines, context)
  self:flip_flop_inlines(inlines)

  self:move_punctuation(inlines)

  -- util.debug(inlines)

  return self:write_inlines(inlines, context)
end

function OutputFormat:output_bibliography_entry(inlines, context)
  self:flip_flop_inlines(inlines)
  -- util.debug(inlines)
  self:move_punctuation(inlines)
  -- TODO:
  -- if self.format == "html" then
  -- elseif self.format == "latex" then
  -- end
  local res = self:write_inlines(inlines, context)
  local markup = self.markups["@bibliography/entry"]
  if type(markup) == "string" then
    res = string.format(markup, res)
  elseif type(markup) == "function" then
    res = markup(res, context)
  end
  return res
end

function OutputFormat:flip_flop_inlines(inlines)
  local flip_flop_state = {
    ["font-style"] = "normal",
    ["font-variant"] = "normal",
    ["font-weight"] = "normal",
    ["text-decoration"] = "none",
    ["vertical-alignment"] = "baseline",
    in_inner_quotes = false,
  }
  self:flip_flop(inlines, flip_flop_state)
end

function OutputFormat:flip_flop(inlines, state)
  for i, inline in ipairs(inlines) do
    if inline._type == "Micro" then
      self:flip_flop_micro_inlines(inline.inlines, state)

    elseif inline._type == "Formatted" then
      local new_state = util.clone(state)
      local formatting = inline.formatting

      for _, attribute in ipairs({"font-style", "font-variant", "font-weight"}) do
        local value = formatting[attribute]
        if value then
          if value == state[attribute] then
            if value == "normal" then
              formatting[attribute] = nil
            -- Formatting outside Micro is not reset to "normal".
            -- else
            --   formatting[attribute] = "normal"
            end
          end
          new_state[attribute] = value
        end
      end
      self:flip_flop(inline.inlines, new_state)

    elseif inline._type == "Quoted" then
      inline.is_inner = state.in_inner_quotes
      local new_state = util.clone(state)
      new_state.in_inner_quotes = not new_state.in_inner_quotes
      self:flip_flop(inline.inlines, new_state)

    elseif inline._type == "NoDecor" then
      local new_state = {
        ["font-style"] = "normal",
        ["font-variant"] = "normal",
        ["font-weight"] = "normal",
        ["text-decoration"] = "none",
      }
      self:flip_flop(inline.inlines, new_state)
      for attr, value in pairs(new_state) do
        if value and state[attr] ~= value then
          if not inline.formatting then
            inline._type = "Formatted"
            inline.formatting = {}
          end
          inline.formatting[attr] = value
        end
      end

    elseif inline.inlines then  -- Div, ...
      self:flip_flop(inline.inlines, state)
    end
  end
end

function OutputFormat:flip_flop_micro_inlines(inlines, state)
  for i, inline in ipairs(inlines) do
    if inline._type == "Micro" then
      self:flip_flop_micro_inlines(inline.inlines, state)

    elseif inline._type == "Formatted" then
      local new_state = util.clone(state)
      local formatting = inline.formatting

      for _, attribute in ipairs({"font-style", "font-variant", "font-weight"}) do
        local value = formatting[attribute]
        if value then
          if value == state[attribute] then
            if value == "normal" then
              formatting[attribute] = nil
            else
              -- Formatting inside Micro is reset to "normal".
              formatting[attribute] = "normal"
              value = "normal"
            end
          end
          new_state[attribute] = value
        end
      end
      self:flip_flop_micro_inlines(inline.inlines, new_state)

    elseif inline._type == "Quoted" then
      inline.is_inner = state.in_inner_quotes
      local new_state = util.clone(state)
      new_state.in_inner_quotes = not new_state.in_inner_quotes
      self:flip_flop_micro_inlines(inline.inlines, new_state)

    elseif inline._type == "NoDecor" then
      local new_state = {
        ["font-style"] = "normal",
        ["font-variant"] = "normal",
        ["font-weight"] = "normal",
        ["text-decoration"] = "none",
      }
      self:flip_flop_micro_inlines(inline.inlines, new_state)
      for attr, value in pairs(new_state) do
        if value and state[attr] ~= value then
          if not inline.formatting then
            inline._type = "Formatted"
            inline.formatting = {}
          end
          inline.formatting[attr] = value
        end
      end

    elseif inline.inlines then  -- Div, ...
      self:flip_flop_micro_inlines(inline.inlines, state)
    end
  end
end

local function find_left(inline)
  if not inline then
    print(debug.traceback())
  end
  if inline._type == "PlainText" then
    return inline
  -- elseif inline._type == "Micro" then
  --   return nil
  elseif inline.inlines and inline._type~="Quoted" then
    return find_left(inline.inlines[1])
  else
    return nil
  end
end

local function find_right(inline)
  if inline._type == "PlainText" then
    return inline
  -- elseif inline._type == "Micro" then
  --   return nil
  elseif inline.inlines and #inline.inlines > 0 and inline._type ~= "Quoted" then
    return find_right(inline.inlines[#inline.inlines])
  else
    return nil
  end
end

local function find_right_in_quoted(inline)
  if inline._type == "PlainText" then
    return inline
  -- elseif inline._type == "Micro" then
  --   return nil
  elseif inline.inlines and #inline.inlines > 0 then
    return find_right_in_quoted(inline.inlines[#inline.inlines])
  else
    return nil
  end
end

-- "'Foo,' bar" => ,
local function find_right_quoted(inline)
  if inline._type == "Quoted" and #inline.inlines > 0 then
    return find_right_in_quoted(inline.inlines[#inline.inlines]), inline.quotes.punctuation_in_quote
  -- elseif inline._type == "Micro" then
  --   return nil
  elseif inline.inlines and #inline.inlines > 0 then
    return find_right_quoted(inline.inlines[#inline.inlines])
  else
    return nil, false
  end
end

local function smash_string_push(first, second)
  local first_char = string.sub(first.value, -1)
  local second_char = string.sub(second.value, 1, 1)
  -- util.debug(first_char)
  -- util.debug(second_char)

  local punct_map = output_module.quote_punctuation_map
  if second_char == " " and (first_char == " " or
      util.endswith(first.value, util.unicode["no-break space"])) then
    second.value = string.sub(second.value, 2)
  elseif punct_map[first_char] then
    if first_char == second_char then
      second.value = string.sub(second.value, 2)
    else
      local combined = punct_map[first_char][second_char]
      if combined and #combined == 1 then
        second.value = string.sub(second.value, 2)
        first.value = string.sub(first.value, 1, -2) .. combined
      end
    end
  end
end

local function smash_just_punc(first, second)
  first = find_right(first)  -- PlainText
  second = find_left(second)  -- PlainText
  if first and second then
    local first_char = string.sub(first.value, -1)
    local second_char = string.sub(second.value, 1, 1)
    -- util.debug(first_char)
    -- util.debug(second_char)

    local punct_map = output_module.quote_punctuation_map
    if second_char == " " and (first_char == " " or
        util.endswith(first.value, util.unicode["no-break space"])) then
      second.value = string.sub(second.value, 2)
      return true
    elseif punct_map[first_char] then
      if first_char == second_char then
        second.value = string.sub(second.value, 2)
        return true
      else
        local combined = punct_map[first_char][second_char]
        if combined and #combined == 1 then
          second.value = string.sub(second.value, 2)
          first.value = string.sub(first.value, 1, -2) .. combined
          return true
        end
      end
    end
  else
    return false
  end
end

local function normalise_text_elements(inlines)
  -- 1. Merge punctuations: "?." => "?"
  -- 2. Merge spaces: "  " => " "
  local idx = 1
  while idx < #inlines do
    local first = inlines[idx]
    local second = inlines[idx+1]

    if first._type == "PlainText" and second._type == "PlainText" then
      smash_string_push(first, second)
      first.value = first.value .. second.value
      table.remove(inlines, idx + 1)

    elseif first._type == "Micro" and second._type == "PlainText" then
      local success = smash_just_punc(first, second)
      if success then
        if second.value == "" then
          table.remove(inlines, idx + 1)
        end
      else
        idx = idx + 1
      end

    elseif first._type == "Formatted" and second._type == "PlainText" then
      local success = smash_just_punc(first, second)
      if success then
        if second.value == "" then
          table.remove(inlines, idx + 1)
        end
      else
        idx = idx + 1
      end

    else
      idx = idx + 1
    end
  end

end

local function move_in_quotes(first, second)
  local first_char = string.sub(first.value, -1)
  local second_char = string.sub(second.value, 1, 1)
  local success = false
  if output_module.move_in_puncts[second_char] then
    if first_char == second_char then
      second.value = string.sub(second.value, 2)
      success = true
    elseif output_module.quote_punctuation_map[first_char] then
      local combined = output_module.quote_punctuation_map[first_char][second_char]
      first.value = string.sub(first.value, 1, -2) .. combined
      second.value = string.sub(second.value, 2)
      success = true
    else
      first.value = first.value .. second_char
      second.value = string.sub(second.value, 2)
      success = true
    end
  end
  return success
end

local function move_out_quotes(first, second)
  local first_char = string.sub(first.value, -1)
  local second_char = string.sub(second.value, 1, 1)
  local success = false
  if output_module.move_out_puncts[first_char] then
    if first_char == second_char then
      first.value = string.sub(first.value, 1, -2)
      success = true
    elseif output_module.quote_punctuation_map[second_char] then
      local combined = output_module.quote_punctuation_map[first_char][second_char]
      first.value = string.sub(first.value, 1, -2)
      second.value = combined .. string.sub(second.value, 2)
      success = true
    else
      first.value = string.sub(first.value, 1, -2)
      second.value = first_char .. second.value
      success = true
    end
  end
  return success
end

local function move_around_quote(inlines)
  local idx = 1

  while idx < #inlines do
    -- Move punctuation into quotes as needed
    local first, punctuation_in_quote = find_right_quoted(inlines[idx])

    local second = find_left(inlines[idx+1])
    local success = false
    if first and second then
      if punctuation_in_quote then
        success = move_in_quotes(first, second)
      else
        success = move_out_quotes(first, second)
      end
      if not success then
        idx = idx + 1
      end
    else
      idx = idx + 1
    end
  end
end

function OutputFormat:move_punctuation(inlines, piq)
  -- Merge punctuations
  normalise_text_elements(inlines)

  move_around_quote(inlines)

  for _, inline in ipairs(inlines) do
    if inline._type == "Quoted" or inline._type == "Formatted" or
        inline._type == "Div" then
      self:move_punctuation(inline.inlines)
    end
  end
end

function OutputFormat:write_inlines(inlines, context)
  local res = ""
  for _, inline in ipairs(inlines) do
    res = res .. self:write_inline(inline, context)
  end
  return res
end

function OutputFormat:write_inline(inline, context)
  if inline.value then
    return self:write_escaped(inline.value)
  elseif inline.inlines then
    return self:write_inlines(inline.inlines)
  end
  return ""
end

function OutputFormat:write_escaped(str, context)
  return str
end

function OutputFormat:write_link(inline, context)
  return self:write_escaped(inline.value)
end



local Markup = OutputFormat:new()

function Markup:write_inline(inline, context)
  -- Should be deprecated code
  if type(inline) == "string" then
    return self:write_escaped(inline, context)

  elseif type(inline) == "table" then
    -- util.debug(inline._type)
    if inline._type == "PlainText" then
      return self:write_escaped(inline.value, context)

    elseif inline._type == "InlineElement" then
      return self:write_children(inline, context)

    elseif inline._type == "Formatted" then
      return self:write_formatted(inline, context)

    elseif inline._type == "Quoted" then
      return self:write_quoted(inline, context)

    elseif inline._type == "Div" then
      return self:write_display(inline, context)

    elseif inline._type == "Linked" then
      return self:write_link(inline, context)

    elseif inline._type == "NoCase" or inline._type == "NoDecor" then
      return self:write_inlines(inline.inlines)

    else
      return self:write_inlines(inline.inlines)
    end
  end
  return ""
end

function Markup:write_children(inline, context)
  local res = ""
  for _, child_inline in ipairs(inline.inlines) do
    res = res .. self:write_inline(child_inline, context)
  end
  return res
end

function Markup:write_quoted(inline, context)
  local res = self:write_children(inline, context)
  local quotes = inline.quotes
  if inline.is_inner then
    return quotes.inner_open .. res .. quotes.inner_close
  else
    return quotes.outer_open .. res .. quotes.outer_close
  end
end


local LatexWriter = Markup:new()

LatexWriter.markups = {
  ["bibstart"] = function (engine)
    return string.format("\\begin{thebibliography}{%s}\n", engine.registry.longest_label)
  end,
  ["bibend"] = "\\end{thebibliography}",
  ["@font-style/normal"] = "{\\normalshape %s}",
  ["@font-style/italic"] = "\\textit{%s}",
  ["@font-style/oblique"] = "\\textsl{%s}",
  ["@font-variant/normal"] = "{\\normalshape %s}",
  ["@font-variant/small-caps"] = "\\textsc{%s}",
  ["@font-weight/normal"] = "{\\fontseries{m}\\selectfont %s}",
  ["@font-weight/bold"] = "\\textbf{%s}",
  ["@font-weight/light"] = "{\\fontseries{l}\\selectfont %s}",
  ["@text-decoration/none"] = false,
  ["@text-decoration/underline"] = "\\underline{%s}",
  ["@vertical-align/sup"] = "\\textsuperscript{%s}",
  ["@vertical-align/sub"] = "\\textsubscript{%s}",
  ["@vertical-align/baseline"] = false,
  ["@cite/entry"] = false,
  ["@bibliography/entry"] = function (str, context)
    if string.match(str, "\\bibitem") then
      str =  str .. "\n"
    else
      str =  "\\bibitem{".. context.id .. "}\n" .. str .. "\n"
    end
    return str
  end,
  -- ["@display/block"] = false,
  -- ["@display/left-margin"] = '\n    <div class="csl-left-margin">%s</div>',
  -- ["@display/right-inline"] = '<div class="csl-right-inline">%s</div>',
  -- ["@display/indent"] = '<div class="csl-indent">%s</div>\n  ',
}

function LatexWriter:write_escaped(str, context)
  -- TeXbook, p. 38
  str = str:gsub("\\", "\\textbackslash{}")
  str = str:gsub("{", "\\{")
  str = str:gsub("}", "\\}")
  str = str:gsub("%$", "\\$")
  str = str:gsub("&", "\\&")
  str = str:gsub("#", "\\#")
  str = str:gsub("%^", "\\^")
  str = str:gsub("_", "\\_")
  str = str:gsub("%%", "\\%%")
  str = str:gsub("~", "\\~")
  str = str:gsub(util.unicode["em space"], "\\quad ")
  str = str:gsub(util.unicode["no-break space"], "~")
  for char, sub in pairs(util.superscripts) do
    str = string.gsub(str, char, "\\textsuperscript{" .. sub .. "}")
  end
  return str
end

function LatexWriter:write_formatted(inline, context)
  local res = self:write_children(inline, context)
  for _, key in ipairs({"font-style", "font-variant", "font-weight", "text-decoration", "vertical-align"}) do
    local value = inline.formatting[key]
    if value then
      key = "@" .. key .. "/" .. value
      local format_str = self.markups[key]
      if format_str then
        res = string.format(format_str, res)
      end
    end
  end
  return res
end

function LatexWriter:write_display(inline, context)
  if inline.div == "left-margin" then
    local plainter_text_writer = output_module.PlainTextWriter:new()
    local str = plainter_text_writer:write_inline(inline)
    local len = utf8.len(str)
    if len > context.engine.registry.maxoffset then
      context.engine.registry.maxoffset = len
      context.engine.registry.longest_label = str
    end
  end

  local res = self:write_children(inline, context)
  if inline.div == "left-margin" then
    if string.match(res, "%]") then
      res = "{" .. res .. "}"
    end
    res = string.format("\\bibitem[%s]{%s}\n", res, context.id)

  elseif inline.div == "right-inline" then
    return res

  elseif inline.div == "block" then
    return ""
  end
  return res
end

function LatexWriter:write_link(inline, context)
  if inline.href == inline.value then
    return string.format("\\url{%s}", inline.value)
  else
    return string.format("\\href{%s}{%s}", inline.href, inline.value)
  end
end


local HtmlWriter = Markup:new()

HtmlWriter.markups = {
  ["bibstart"] = "<div class=\"csl-bib-body\">\n",
  ["bibend"] = "</div>",
  ["@font-style/italic"] = "<i>%s</i>",
  ["@font-style/oblique"] = "<em>%s</em>",
  ["@font-style/normal"] = '<span style="font-style:normal;">%s</span>',
  ["@font-variant/small-caps"] = '<span style="font-variant:small-caps;">%s</span>',
  ["@font-variant/normal"] = '<span style="font-variant:normal;">%s</span>',
  ["@font-weight/bold"] = "<b>%s</b>",
  ["@font-weight/normal"] = '<span style="font-weight:normal;">%s</span>',
  ["@font-weight/light"] = false,
  ["@text-decoration/none"] = '<span style="text-decoration:none;">%s</span>',
  ["@text-decoration/underline"] = '<span style="text-decoration:underline;">%s</span>',
  ["@vertical-align/sup"] = "<sup>%s</sup>",
  ["@vertical-align/sub"] = "<sub>%s</sub>",
  ["@vertical-align/baseline"] = '<span style="baseline">%s</span>',
  ["@cite/entry"] = nil,
  ["@bibliography/entry"] = "<div class=\"csl-entry\">%s</div>\n",
  ["@display/block"] = '\n\n    <div class="csl-block">%s</div>\n',
  ["@display/left-margin"] = '\n    <div class="csl-left-margin">%s</div>',
  ["@display/right-inline"] = '<div class="csl-right-inline">%s</div>\n  ',
  ["@display/indent"] = '<div class="csl-indent">%s</div>\n  ',
}

function HtmlWriter:write_escaped(str, context)
  str = string.gsub(str, "%&", "&#38;")
  str = string.gsub(str, "<", "&#60;")
  str = string.gsub(str, ">", "&#62;")
  for char, sub in pairs(util.superscripts) do
    str = string.gsub(str, char, "<sup>" .. sub .. "</sup>")
  end
  return str
end

function HtmlWriter:write_formatted(inline, context)
  local res = self:write_children(inline, context)
  for _, key in ipairs({"font-style", "font-variant", "font-weight", "text-decoration", "vertical-align"}) do
    local value = inline.formatting[key]
    if value then
      key = "@" .. key .. "/" .. value
      local format_str = self.markups[key]
      if format_str then
        res = string.format(format_str, res)
      end
    end
  end
  return res
end

function HtmlWriter:write_display(inline, context)
  if inline.div == "left-margin" then
    local plainter_text_writer = output_module.PlainTextWriter:new()
    local str = plainter_text_writer:write_inline(inline)
    local len = utf8.len(str)
    if len > context.engine.registry.maxoffset then
      context.engine.registry.maxoffset = len
      context.engine.registry.longest_label = str
    end
  end

  if #inline.inlines == 0 then
    return ""
  end
  local res = self:write_children(inline, context)
  local key = string.format("@display/%s", inline.div)
  if inline.div == "right-inline" then
    -- Strip trailing spaces
    -- variables_ContainerTitleShort.txt
    res = string.gsub(res, "%s+$", "")
  end
  local format_str = self.markups[key]
  res = string.format(format_str, res)
  return res
end

function HtmlWriter:write_link(inline, context)
  return string.format('<a href="%s">%s</a>', inline.href, inline.value)
end


local PlainTextWriter = Markup:new()

PlainTextWriter.markups = {}

function PlainTextWriter:write_escaped(str, context)
  return str
end

function PlainTextWriter:write_formatted(inline, context)
  return self:write_children(inline, context)
end

function PlainTextWriter:write_display(inline, context)
  return self:write_children(inline, context)
end


local SortStringFormat = OutputFormat:new()

function SortStringFormat:output(inlines, context)
  -- self:flip_flop_inlines(inlines)
  -- self:move_punctuation(inlines)
  return self:write_inlines(inlines, context)
end

function SortStringFormat:write_escaped(str, context)
  str = string.gsub(str, ",", "")
  return str
end


local DisamStringFormat = OutputFormat:new()

function DisamStringFormat:output(inlines, context)
  -- self:flip_flop_inlines(inlines)
  -- self:move_punctuation(inlines)
  return self:write_inlines(inlines, context)
end

function DisamStringFormat:flatten_ir(ir)
  if self.group_var == "missing" then
    return {}
  end
  local inlines
  if ir._type == "SeqIr" or ir._type == "NameIr" then
    inlines = self:flatten_seq_ir(ir)
  elseif ir._type == "YearSuffix" then
    -- Don't include year-suffix in disambiguation
    inlines = {}
  else
    inlines = self:affixed_quoted(ir.inlines, ir.affixes, ir.quotes);
    inlines = self:with_display(inlines, ir.display);
  end
  return inlines
end

function DisamStringFormat:flatten_seq_ir(ir)
  if not ir.children then
    print(debug.traceback())
  end
  if #ir.children == 0 then
    return {}
  end
  local inlines_list = {}
  for _, child in ipairs(ir.children) do
    if child.group_var ~= "missing" then
      -- and not child.collapse_suppressed
      -- Suppressed irs are stil kept in the DisamStringFormat
      table.insert(inlines_list, self:flatten_ir(child))
    end
  end

  local inlines = self:group(inlines_list, ir.delimiter, ir.formatting)
  -- assert ir.quotes == localized quotes
  inlines = self:affixed_quoted(inlines, ir.affixes, ir.quotes);
  inlines = self:with_display(inlines, ir.display);
  return inlines
end


output_module.move_in_puncts = {
  ["."] = true,
  ["!"] = true,
  ["?"] = true,
  [","] = true,
}

output_module.move_out_puncts = {
  [","] = true,
  [";"] = true,
  [":"] = true,
}

-- https://github.com/Juris-M/citeproc-js/blob/aa2683f48fe23be459f4ed3be3960e2bb56203f0/src/queue.js#L724
-- Also merge duplicate punctuations.
output_module.quote_punctuation_map = {
  ["!"] = {
    ["."] = "!",
    ["?"] = "!?",
    [":"] = "!",
    [","] = "!,",
    [";"] = "!;",
  },
  ["?"] = {
    ["!"] = "?!",
    ["."] = "?",
    [":"] = "?",
    [","] = "?,",
    [";"] = "?;",
  },
  ["."] = {
    ["!"] = ".!",
    ["?"] = ".?",
    [":"] = ".:",
    [","] = ".,",
    [";"] = ".;",
  },
  [":"] = {
    ["!"] = "!",
    ["?"] = "?",
    ["."] = ":",
    [","] = ":,",
    [";"] = ":;",
  },
  [","] = {
    ["!"] = ",!",
    ["?"] = ",?",
    [":"] = ",:",
    ["."] = ",.",
    [";"] = ",;",
  },
  [";"] = {
    ["!"] = "!",
    ["?"] = "?",
    [":"] = ";",
    [","] = ";,",
    ["."] = ";",
  }
}


output_module.LocalizedQuotes = LocalizedQuotes

output_module.InlineElement = InlineElement
output_module.PlainText = PlainText
output_module.Formatted = Formatted
output_module.Micro = Micro
output_module.Quoted = Quoted
output_module.Linked = Linked
output_module.Div = Div
output_module.NoCase = NoCase
output_module.NoDecor = NoDecor

output_module.OutputFormat = OutputFormat

output_module.Markup = Markup
output_module.LatexWriter = LatexWriter
output_module.HtmlWriter = HtmlWriter
output_module.PlainTextWriter = PlainTextWriter
output_module.DisamStringFormat = DisamStringFormat
output_module.SortStringFormat = SortStringFormat

return output_module
