local env_types = {
  theorem = true,
  lemma = true,
  proposition = true,
  corollary = true,
  definition = true,
  example = true,
  remark = true,
  proof = true,
}

traverse = "topdown"

local callout_labels = {
  aside = "Aside",
  caution = "Caution",
  note = "Note",
  tip = "Tip",
  warning = "Warning",
}

local callout_envs = {
  aside = "blogasidebox",
  caution = "blogcautionbox",
  note = "blognotebox",
  tip = "blogtipbox",
  warning = "blogwarningbox",
}

local callout_markers = {
  { marker = "📝", key = "note" },
  { marker = "💡", key = "tip" },
  { marker = "⚡", key = "tip" },
  { marker = "⚠️", key = "warning" },
  { marker = "⚠", key = "warning" },
}

local chess_macros = {
  ["♔"] = "\\BlogChessWhite{\\symking}",
  ["♕"] = "\\BlogChessWhite{\\symqueen}",
  ["♖"] = "\\BlogChessWhite{\\symrook}",
  ["♗"] = "\\BlogChessWhite{\\symbishop}",
  ["♘"] = "\\BlogChessWhite{\\symknight}",
  ["♙"] = "\\BlogChessWhite{\\sympawn}",
  ["♚"] = "\\BlogChessBlack{\\symking}",
  ["♛"] = "\\BlogChessBlack{\\symqueen}",
  ["♜"] = "\\BlogChessBlack{\\symrook}",
  ["♝"] = "\\BlogChessBlack{\\symbishop}",
  ["♞"] = "\\BlogChessBlack{\\symknight}",
  ["♟"] = "\\BlogChessBlack{\\sympawn}",
}

local card_macros = {
  ["♠"] = "\\BlogCardBlack{\\ensuremath{\\spadesuit}}",
  ["♤"] = "\\BlogCardBlack{\\ensuremath{\\spadesuit}}",
  ["♣"] = "\\BlogCardBlack{\\ensuremath{\\clubsuit}}",
  ["♧"] = "\\BlogCardBlack{\\ensuremath{\\clubsuit}}",
  ["♥"] = "\\BlogCardRed{\\ensuremath{\\heartsuit}}",
  ["♡"] = "\\BlogCardRed{\\ensuremath{\\heartsuit}}",
  ["♦"] = "\\BlogCardRed{\\ensuremath{\\diamondsuit}}",
  ["♢"] = "\\BlogCardRed{\\ensuremath{\\diamondsuit}}",
}

local function has_class(el, class_name)
  for _, value in ipairs(el.classes) do
    if value == class_name then
      return true
    end
  end
  return false
end

local function first_env_class(el)
  for _, value in ipairs(el.classes) do
    if env_types[value] then
      return value
    end
  end
  return nil
end

local function normalize_label(value)
  value = tostring(value or ""):lower()
  value = value:gsub("^%s+", ""):gsub("%s+$", "")
  value = value:gsub("^%[!", ""):gsub("%]$", "")
  value = value:gsub(":$", "")
  return value
end

local function inline_text(inline)
  if not inline then
    return ""
  end
  return pandoc.utils.stringify(inline)
end

local function first_inline_text(block)
  if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
    return ""
  end
  return inline_text(block.content[1])
end

local function latex_escape(value)
  value = tostring(value or "")
  value = value:gsub("\\", "\\textbackslash{}")
  value = value:gsub("([{}%%$&#_])", "\\%1")
  value = value:gsub("%^", "\\textasciicircum{}")
  value = value:gsub("~", "\\textasciitilde{}")
  return value
end

local function codepoint_at(value, index)
  return utf8.codepoint(value, index, index)
end

local function codepoint_len(value, index)
  local byte = string.byte(value, index)
  if not byte then
    return 0
  elseif byte < 0x80 then
    return 1
  elseif byte < 0xE0 then
    return 2
  elseif byte < 0xF0 then
    return 3
  end
  return 4
end

local function char_at(value, index)
  local len = codepoint_len(value, index)
  return value:sub(index, index + len - 1), index + len
end

local function is_variation_selector(cp)
  return cp == 0xFE0E or cp == 0xFE0F
end

local function is_skin_tone(cp)
  return cp >= 0x1F3FB and cp <= 0x1F3FF
end

local function is_emoji_base(cp)
  return (cp >= 0x1F000 and cp <= 0x1FAFF)
    or (cp >= 0x2600 and cp <= 0x27BF)
    or cp == 0x00A9
    or cp == 0x00AE
    or cp == 0x203C
    or cp == 0x2049
    or cp == 0x2122
    or cp == 0x2139
    or cp == 0x3030
    or cp == 0x303D
    or cp == 0x3297
    or cp == 0x3299
end

local function emoji_cluster(value, index)
  local start = index
  local current, next_index = char_at(value, index)
  if current == "" then
    return "", index
  end

  index = next_index

  while index <= #value do
    local cp = codepoint_at(value, index)
    if is_variation_selector(cp) or is_skin_tone(cp) then
      local _, ni = char_at(value, index)
      index = ni
    elseif cp == 0x200D then
      local _, ni = char_at(value, index)
      index = ni
      if index <= #value then
        local _, after_joined = char_at(value, index)
        index = after_joined
      end
    else
      break
    end
  end

  return value:sub(start, index - 1), index
end

local function symbol_macro_for_cluster(cluster)
  local cp = utf8.codepoint(cluster, 1, 1)
  if not cp then
    return nil
  end

  if chess_macros[cluster] then
    return chess_macros[cluster]
  end

  if card_macros[cluster] then
    return card_macros[cluster]
  end

  if cp >= 0x1F0A0 and cp <= 0x1F0FF then
    return "\\BlogEmojiText{" .. cluster .. "}"
  end

  if is_emoji_base(cp) then
    return "\\BlogEmojiText{" .. cluster .. "}"
  end

  return nil
end

local function symbol_str_to_latex(el)
  if not FORMAT:match("latex") then
    return nil
  end

  local text = el.text
  local pieces = pandoc.List()
  local plain = {}
  local changed = false
  local index = 1

  local function flush_plain()
    if #plain > 0 then
      pieces:insert(pandoc.Str(table.concat(plain)))
      plain = {}
    end
  end

  while index <= #text do
    local cp = codepoint_at(text, index)
    if cp and (chess_macros[utf8.char(cp)] or card_macros[utf8.char(cp)] or is_emoji_base(cp)) then
      local cluster, next_index = emoji_cluster(text, index)
      local macro = symbol_macro_for_cluster(cluster)
      if macro then
        flush_plain()
        pieces:insert(pandoc.RawInline("latex", macro))
        changed = true
        index = next_index
      else
        local char, ni = char_at(text, index)
        plain[#plain + 1] = char
        index = ni
      end
    else
      local char, ni = char_at(text, index)
      plain[#plain + 1] = char
      index = ni
    end
  end

  flush_plain()

  if changed then
    return pieces
  end
  return nil
end

local function internal_link_to_cref(el)
  if FORMAT:match("latex") and el.target:sub(1, 1) == "#" then
    local label = el.target:sub(2)
    if label ~= "" then
      return pandoc.RawInline("latex", "\\cref{" .. label .. "}")
    end
  end
  return nil
end

local function latexize_doc(doc)
  return doc:walk({
    Link = internal_link_to_cref,
    Str = symbol_str_to_latex,
  })
end

local function markdown_to_latex(markdown)
  local doc = pandoc.read(
    markdown or "",
    "markdown+fenced_code_attributes+tex_math_dollars+tex_math_single_backslash+footnotes+raw_html"
  )
  doc = latexize_doc(doc)
  return pandoc.write(doc, "latex")
end

local function blocks_to_latex(blocks)
  local doc = latexize_doc(pandoc.Pandoc(blocks))
  return pandoc.write(doc, "latex")
end

local function label_line(label)
  if label and label ~= "" then
    return "\\label{" .. label .. "}\n"
  end
  return ""
end

local function interactive_to_latex(el)
  local label = el.identifier
  local caption = el.attributes.caption or el.attributes.title or ""
  local fallback = el.attributes.pdf or el.attributes.fallback or el.text
  local fallback_latex = markdown_to_latex(fallback)
  local caption_line = ""

  if caption ~= "" then
    caption_line = "\\caption{" .. latex_escape(caption) .. "}\n"
  end

  return pandoc.RawBlock(
    "latex",
    table.concat({
      "\\begin{figure}[htbp]\n",
      "\\centering\n",
      "\\fbox{\\begin{minipage}{0.86\\linewidth}\n",
      fallback_latex,
      "\n\\end{minipage}}\n",
      caption_line,
      label_line(label),
      "\\end{figure}",
    })
  )
end

local function remove_first_inline(block)
  if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
    return
  end

  table.remove(block.content, 1)
  while #block.content > 0 and block.content[1].t == "Space" do
    table.remove(block.content, 1)
  end

  if #block.content > 0 and block.content[1].t == "Str" then
    block.content[1].text = block.content[1].text:gsub("^:%s*", "")
    if block.content[1].text == "" then
      table.remove(block.content, 1)
    end
  end
end

local function strip_text_prefix(block, pattern)
  if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
    return
  end

  local first = block.content[1]
  if first.t ~= "Str" then
    return
  end

  first.text = first.text:gsub(pattern, "")
  if first.text == "" then
    remove_first_inline(block)
  end
end

local function detect_callout(block)
  local text = first_inline_text(block)
  if text == "" then
    return nil
  end

  if block.content[1].t == "Strong" then
    local key = normalize_label(text)
    if callout_labels[key] then
      return { key = key, label = callout_labels[key], remover = "strong" }
    end
  end

  local bracket = text:match("^%[!([%a-]+)%]")
  if bracket then
    local key = normalize_label(bracket)
    if callout_labels[key] then
      return { key = key, label = callout_labels[key], remover = "bracket" }
    end
  end

  for _, item in ipairs(callout_markers) do
    if text:sub(1, #item.marker) == item.marker then
      return { key = item.key, label = callout_labels[item.key], remover = "marker", marker = item.marker }
    end
  end

  return nil
end

local function callout_to_latex(el)
  if not FORMAT:match("latex") or not el.content or #el.content == 0 then
    return nil
  end

  local blocks = pandoc.List(el.content)
  local callout = detect_callout(blocks[1])
  if not callout then
    return nil
  end

  if callout.remover == "strong" then
    remove_first_inline(blocks[1])
  elseif callout.remover == "bracket" then
    strip_text_prefix(blocks[1], "^%[![%a-]+%]%s*")
  elseif callout.remover == "marker" then
    strip_text_prefix(blocks[1], "^" .. callout.marker .. "%s*")
  end

  if blocks[1] and blocks[1].t == "Para" and #blocks[1].content == 0 then
    table.remove(blocks, 1)
  end

  local env = callout_envs[callout.key] or "blognotebox"
  return pandoc.RawBlock(
    "latex",
    "\\begin{" .. env .. "}{}\n"
      .. blocks_to_latex(blocks)
      .. "\n\\end{" .. env .. "}"
  )
end

local function env_to_latex(el)
  local env_type = el.attributes.type or first_env_class(el)
  if not env_type or not env_types[env_type] then
    return nil
  end

  local title = el.attributes.title or el.attributes.name or ""
  local begin_env = "\\begin{" .. env_type .. "}"
  if title ~= "" then
    begin_env = begin_env .. "[" .. latex_escape(title) .. "]"
  end

  return pandoc.RawBlock(
    "latex",
    table.concat({
      begin_env,
      "\n",
      label_line(el.identifier),
      markdown_to_latex(el.text),
      "\n\\end{",
      env_type,
      "}",
    })
  )
end

function CodeBlock(el)
  if FORMAT:match("latex") and has_class(el, "interactive") then
    return interactive_to_latex(el)
  end

  if FORMAT:match("latex") and (has_class(el, "env") or has_class(el, "latex-env") or first_env_class(el)) then
    return env_to_latex(el)
  end

  return nil
end

function BlockQuote(el)
  return callout_to_latex(el)
end

function Link(el)
  return internal_link_to_cref(el)
end

function Str(el)
  return symbol_str_to_latex(el)
end
