-- lx-bundle:true
--
-- tidyxml.lua — simple XML pretty-printer / minifier for Lite XL
-- Uses a vendored subset of https://github.com/jonathanpoelen/lua-xmlparser
--
-- Limitations (important):
-- - Strips comments (parser removes them)
-- - Drops processing instructions like <?xml ...?> (parser ignores them)
-- - No CDATA support
-- - Whitespace in text nodes is trimmed (parser behavior)
-- - Not a full-fidelity round-tripper for arbitrary XML

local core = require("core")
local command = require("core.command")
local config = require("core.config")

config.tidyxml_indent_size = config.tidyxml_indent_size or 2
config.tidyxml_use_tabs = (config.tidyxml_use_tabs or true) == true
config.tidyxml_inline_text_nodes = (config.tidyxml_inline_text_nodes or true) == true

local function get_indent_unit()
  if config.tidyxml_use_tabs then
    return "\t"
  end
  local width = tonumber(config.tidyxml_indent_size) or 2
  if width < 0 then width = 0 end
  return string.rep(" ", math.floor(width))
end

-----------------------------------------------------------------------
-- Vendored xmlparser (slightly adjusted: local names, no io.parseFile)
-----------------------------------------------------------------------

local slashchar = string.byte("/", 1)
local E = string.byte("E", 1)

local function defaultEntityTable()
  return { quot='"', apos="'", lt="<", gt=">", amp="&", tab="\t", nbsp=" ", }
end

local function replaceEntities(s, entities)
  return s:gsub("&([^;]+);", entities)
end

local function createEntityTable(docEntities, resultEntities)
  local entities = resultEntities or defaultEntityTable()
  for _, e in pairs(docEntities) do
    e.value = replaceEntities(e.value, entities)
    entities[e.name] = e.value
  end
  return entities
end

local function xml_parse(s, evalEntities)
  -- remove comments (lossy)
  s = s:gsub("<!%-%-(.-)%-%->", "")

  local entities, tentities = {}, {}

  if evalEntities then
    local pos = s:find("<[_%w]")
    if pos then
      s:sub(1, pos):gsub("<!ENTITY%s+([_%w]+)%s+(.)(.-)%2", function(name, _, entity)
        entities[#entities + 1] = { name = name, value = entity }
      end)
      tentities = createEntityTable(entities)
      s = replaceEntities(s:sub(pos), tentities)
    end
  end

  local t, l = {}, {}

  local function addtext(txt)
    txt = txt:match("^%s*(.*%S)") or ""
    if #txt ~= 0 then
      t[#t + 1] = { text = txt }
    end
  end

  s:gsub("<([?!/]?)([-:_%w]+)%s*(/?>?)([^<]*)", function(type_, name, closed, txt)
    -- open tag
    if #type_ == 0 then
      local attrs, orderedattrs = {}, {}
      if #closed == 0 then
        local len = 0
        for all, aname, _, value, starttxt in string.gmatch(
          txt, "(.-([-_%w]+)%s*=%s*(.)(.-)%3%s*(/?>?))"
        ) do
          len = len + #all
          attrs[aname] = value
          orderedattrs[#orderedattrs + 1] = { name = aname, value = value }
          if #starttxt ~= 0 then
            txt = txt:sub(len + 1)
            closed = starttxt
            break
          end
        end
      end

      t[#t + 1] = { tag = name, attrs = attrs, children = {}, orderedattrs = orderedattrs }

      if closed:byte(1) ~= slashchar then
        l[#l + 1] = t
        t = t[#t].children
      end

      addtext(txt)

    -- close tag
    elseif type_ == "/" then
      t = l[#l]
      l[#l] = nil
      addtext(txt)

    -- ENTITY (partial)
    elseif type_ == "!" then
      if E == name:byte(1) then
        txt:gsub("([_%w]+)%s+(.)(.-)%2", function(nm, _, entity)
          entities[#entities + 1] = { name = nm, value = entity }
        end, 1)
      end
    end
  end)

  return { children = t, entities = entities, tentities = tentities }
end

-----------------------------------------------------------------------
-- Formatter (serialize back to XML)
-----------------------------------------------------------------------

local function escape_text(s)
  -- Keep it minimal and safe
  s = s:gsub("&", "&amp;")
  s = s:gsub("<", "&lt;")
  s = s:gsub(">", "&gt;")
  return s
end

local function escape_attr(s)
  s = escape_text(s)
  s = s:gsub('"', "&quot;")
  return s
end

local function has_only_single_text_child(node)
  if not node.children or #node.children ~= 1 then
    return false
  end
  return node.children[1].text ~= nil
end

local function serialize_node(node, indent_unit, level, pretty)
  if node.text ~= nil then
    return escape_text(node.text)
  end

  local pad = pretty and string.rep(indent_unit, level) or ""
  local child_pad = pretty and string.rep(indent_unit, level + 1) or ""

  local parts = {}
  parts[#parts + 1] = pad
  parts[#parts + 1] = "<"
  parts[#parts + 1] = node.tag

  -- Attributes (preserve input order if available)
  if node.orderedattrs and #node.orderedattrs > 0 then
    for i = 1, #node.orderedattrs do
      local a = node.orderedattrs[i]
      parts[#parts + 1] = " "
      parts[#parts + 1] = a.name
      parts[#parts + 1] = '="'
      parts[#parts + 1] = escape_attr(a.value or "")
      parts[#parts + 1] = '"'
    end
  elseif node.attrs then
    -- Fallback: unordered (pairs)
    for k, v in pairs(node.attrs) do
      parts[#parts + 1] = " "
      parts[#parts + 1] = k
      parts[#parts + 1] = '="'
      parts[#parts + 1] = escape_attr(v or "")
      parts[#parts + 1] = '"'
    end
  end

  local children = node.children or {}
  if #children == 0 then
    parts[#parts + 1] = "/>"
    return table.concat(parts)
  end

  -- Inline single text node, if enabled
  if pretty and config.tidyxml_inline_text_nodes and has_only_single_text_child(node) then
    parts[#parts + 1] = ">"
    parts[#parts + 1] = escape_text(children[1].text or "")
    parts[#parts + 1] = "</"
    parts[#parts + 1] = node.tag
    parts[#parts + 1] = ">"
    return table.concat(parts)
  end

  parts[#parts + 1] = ">"

  if pretty then
    parts[#parts + 1] = "\n"
    for i = 1, #children do
      local c = children[i]
      -- child serializer already includes its own indentation
      parts[#parts + 1] = serialize_node(
        c, indent_unit, level + 1, pretty
      )
      parts[#parts + 1] = "\n"
    end
    parts[#parts + 1] = pad
    parts[#parts + 1] = "</"
    parts[#parts + 1] = node.tag
    parts[#parts + 1] = ">"
    return table.concat(parts)
  end

  -- Minify mode: no added whitespace
  for i = 1, #children do
    parts[#parts + 1] = serialize_node(children[i], indent_unit, level + 1, false)
  end
  parts[#parts + 1] = "</"
  parts[#parts + 1] = node.tag
  parts[#parts + 1] = ">"
  return table.concat(parts)
end

local function tidyxml(text, indent_unit, pretty)
  local doc = xml_parse(text, true)

  -- If there are multiple top-level nodes, we serialize them in order
  local out = {}
  local children = doc.children or {}
  for i = 1, #children do
    out[#out + 1] = serialize_node(children[i], indent_unit, 0, pretty)
    if pretty and i < #children then
      out[#out + 1] = "\n"
    end
  end
  return table.concat(out)
end

-----------------------------------------------------------------------
-- Commands
-----------------------------------------------------------------------

local function show_lossy_warning_once()
  if config._tidyxml_warned then return end
  config._tidyxml_warned = true
  core.status_view:show_message("tidyxml: warning — comments / <?...?> / CDATA are not preserved by this formatter")
end

command.add({
  ["tidyxml:format"] = {
    requires = { "doc", "edit" },
    perform = function()
      show_lossy_warning_once()
      local doc = core.active_view.doc
      local indent = get_indent_unit()

      local ok, err = pcall(function()
        doc:replace(function(text)
          return tidyxml(text, indent, true)
        end)
      end)

      if not ok then
        core.status_view:show_error("Error in XML: " .. tostring(err))
      else
        core.status_view:show_success("XML tidied")
      end
    end,
  },

  ["tidyxml:minimize"] = {
    requires = { "doc", "edit" },
    perform = function()
      show_lossy_warning_once()
      local doc = core.active_view.doc

      local ok, err = pcall(function()
        doc:replace(function(text)
          return tidyxml(text, "", false)
        end)
      end)

      if not ok then
        core.status_view:show_error("Error in XML: " .. tostring(err))
      else
        core.status_view:show_success("XML minimized")
      end
    end,
  },
})

return true
