--- @class (private) vim.undotree.tree.entry
--- @field child integer[]
--- @field time integer

--- @alias vim.undotree.tree {[integer]: vim.undotree.tree.entry}

local M = {}

local ns = vim.api.nvim_create_namespace('nvim.undotree')

--- @param buf integer
--- @return vim.fn.undotree.entry[]
--- @return integer
local function get_undotree_entries(buf)
  local undotree = vim.fn.undotree(buf)
  local entries = undotree.entries

  --Maybe: `:undo 0` and then `undotree` to get seq 0 time
  table.insert(entries, 1, { seq = 0, time = -1 })

  return entries, undotree.seq_cur
end

--- @param ent vim.fn.undotree.entry[]
--- @param _tree vim.undotree.tree?
--- @param _last integer?
--- @return vim.undotree.tree
local function treefy(ent, _tree, _last)
  local tree = _tree or {}
  local last = _last or nil

  for idx, v in ipairs(ent) do
    local seq = v.seq

    if last then
      table.insert(tree[last].child, seq)
    else
      assert(idx == 1 and not _tree)
    end

    tree[seq] = { child = {}, time = v.time }
    if v.alt then
      assert(last)
      treefy(v.alt, tree, last)
    end
    last = seq
  end

  return tree
end

--- @class (private) vim.undotree.graph_line
--- @field kind 'node'|'remove'|'branch'|'remove+branch'|'nochange_remove'
--- @field index integer
--- @field node_count integer
--- @field node integer|integer[]
--- @field index2 integer? -- for branch-index in `remove+branch`

--- @param tree vim.undotree.tree
--- @return vim.undotree.graph_line[]
local function tree_to_graph_lines(tree)
  --- @type vim.undotree.graph_line[]
  local graph_lines = {}

  assert(tree[0], "tree doesn't have 0-th node")
  --- @type (integer[]|integer)[]
  local nodes = { 0 }

  while #nodes > 0 do
    local minseq = math.huge
    --- @type integer
    local index
    --- @type integer
    local node_index

    for k, v in ipairs(nodes) do
      if type(v) == 'table' then
        for i, j in ipairs(v) do
          if j < minseq then
            minseq = j
            index = k
            node_index = i
          end
        end
      elseif v < minseq then
        assert(type(v) == 'number')
        minseq = v
        index = k
      end
    end

    local node = nodes[index]

    --- @param kind 'node'|'remove'|'branch'|'nochange_remove'
    local function add_graph_line(kind)
      table.insert(graph_lines, { kind = kind, index = index, node_count = #nodes, node = node })
    end

    if type(node) == 'number' then
      add_graph_line('node')

      local child = tree[node].child
      if #child == 0 then
        if index ~= #nodes then
          add_graph_line('remove')
        else
          add_graph_line('nochange_remove')
        end

        table.remove(nodes, index)
      elseif #child == 1 then
        nodes[index] = child[1]
      else
        nodes[index] = child
      end
    else
      assert(type(node) == 'table')

      add_graph_line('branch')

      table.remove(nodes, index)
      if #node == 2 then
        table.insert(nodes, index, math.min(unpack(node)))
        table.insert(nodes, index, math.max(unpack(node)))
      elseif #node > 2 then
        table.insert(nodes, index, node[node_index])
        table.insert(nodes, index, node)
        table.remove(node, node_index)
      end
    end
  end

  for k, v in ipairs(graph_lines) do
    if v.kind == 'remove' and (graph_lines[k + 1] or {}).kind == 'branch' then
      v.kind = 'remove+branch'
      v.index2 = graph_lines[k + 1].index
      table.remove(graph_lines, k + 1)
    end
  end

  return graph_lines
end

--- @param time integer
--- @return string
local function undo_fmt_time(time)
  if time == -1 then
    return 'origin'
  end

  local diff = os.time() - time

  if diff >= 100 then
    if diff < (60 * 60 * 12) then
      return os.date('%H:%M:%S', time) --[[@as string]]
    else
      return os.date('%Y/%m/%d %H:%M:%S', time) --[[@as string]]
    end
  else
    return ('%d second%s ago'):format(diff, diff == 1 and '' or 's')
  end
end

--- @param tree vim.undotree.tree
--- @param graph_lines vim.undotree.graph_line[]
--- @param buf integer
--- @param meta {[integer]:integer}
--- @param find_seq? integer
--- @return integer?
local function buf_apply_graph_lines(tree, graph_lines, buf, meta, find_seq)
  -- As in io-buffer, not vim-buffer
  local line_buffer = {}
  local extmark_buffer = {}

  --- @type integer?
  local found_seq

  for k, v in ipairs(graph_lines) do
    local is_last = k == #graph_lines

    --- @type string?
    local line
    if v.kind == 'node' then
      line = ('| '):rep(v.index - 1)
        .. '*'
        .. (' |'):rep(v.node_count - v.index)
        .. '    '
        .. v.node
        .. '    ('
        .. undo_fmt_time(tree[v.node].time)
        .. ')'
    elseif v.kind == 'remove' then
      line = ('| '):rep(v.index - 1) .. (' /'):rep(v.node_count - v.index)
    elseif v.kind == 'branch' then
      line = ('| '):rep(v.index - 1) .. '|\\' .. (' \\'):rep(v.node_count - v.index)
    elseif v.kind == 'remove+branch' then
      if v.index2 < v.index then
        line = ('| '):rep(v.index2 - 1)
          .. '|\\'
          .. (' \\'):rep(v.index - v.index2 - 1)
          .. ' '
          .. (' |'):rep(v.node_count - v.index)
      else
        line = ('| '):rep(v.index - 1)
          .. (' /'):rep(v.index2 - v.index)
          .. ' /|'
          .. (' |'):rep(v.node_count - v.index2 - 1)
      end
    elseif v.kind == 'nochange_remove' then
      line = nil
    else
      error 'unreachable'
    end

    if v.kind == 'node' then
      table.insert(line_buffer, line)
      table.insert(meta, v.node)

      if v.node == find_seq then
        found_seq = #meta
      end
    elseif line then
      table.insert(extmark_buffer, { { line, 'Normal' } })
    end

    if next(extmark_buffer) and (v.kind == 'node' or is_last) then
      local row = vim.api.nvim_buf_line_count(buf)
      vim.api.nvim_buf_set_extmark(buf, ns, row - 1, 0, { virt_lines = extmark_buffer })
      extmark_buffer = {}
    end

    if next(line_buffer) and (v.kind ~= 'node' or is_last) then
      vim.api.nvim_buf_set_lines(buf, -1, -1, true, line_buffer)

      if #line_buffer > 3 then
        local end_ = vim.api.nvim_buf_line_count(buf) - 1
        local start = end_ - #line_buffer + 3
        vim.api.nvim_buf_call(buf, function()
          local w = vim.b[buf].nvim_is_undotree
          if vim.api.nvim_win_is_valid(w) and vim.wo[w].foldmethod == 'manual' then
            vim.cmd.fold { range = { start, end_ } }
          end
        end)
      end

      line_buffer = {}
    end
  end

  vim.api.nvim_buf_set_lines(buf, 0, 1, true, {})

  return found_seq
end

---@param inbuf integer
---@param outbuf integer
---@return {[integer]:integer}
local function draw(inbuf, outbuf)
  local entries, curseq = get_undotree_entries(inbuf)
  local tree = treefy(entries)
  local graph_lines = tree_to_graph_lines(tree)

  local meta = {}
  vim.bo[outbuf].modifiable = true
  vim.api.nvim_buf_set_lines(outbuf, 0, -1, true, {})
  vim.api.nvim_buf_clear_namespace(outbuf, ns, 0, -1)
  local curseq_line = buf_apply_graph_lines(tree, graph_lines, outbuf, meta, curseq)
  vim.bo[outbuf].modifiable = false

  vim.schedule(function()
    if vim.api.nvim_win_is_valid(vim.b[outbuf].nvim_is_undotree) then
      vim.api.nvim_win_set_cursor(vim.b[outbuf].nvim_is_undotree, { curseq_line, 0 })
    end
  end)

  return meta
end

--- @class vim.undotree.opts
--- @inlinedoc
---
--- Buffer to draw the tree into. If omitted, a new buffer is created.
--- @field bufnr integer?
---
--- Window id to display the tree buffer in. If omitted, a new window is
--- created with {command}.
--- @field winid integer?
---
--- Vimscript command to create the window. Default value is "30vnew".
--- Only used when {winid} is nil.
--- @field command string?
---
--- Title of the window. If a function, it accepts the buffer number of the
--- source buffer as its only argument and should return a string.
--- @field title (string|fun(bufnr:integer):string|nil)?

--- Open a window that displays a textual representation of the [undo-tree], or closes the window if
--- it is already open. Can also be shown with `:Undotree`. [:Undotree]()
---
--- While in the window, moving the cursor changes the undo.
---
--- The plugin is not loaded by default; use `:packadd` to activate it:
--- ```
--- :packadd nvim.undotree
--- ```
---
--- @param opts vim.undotree.opts?
--- @return boolean? Returns true if the window was already open, nil otherwise
function M.open(opts)
  -- The following lines of code was copied from
  -- `vim.treesitter.dev.inspect_tree` and then modified to fit

  vim.validate('opts', opts, 'table', true)

  opts = opts or {}

  local buf = vim.api.nvim_get_current_buf()

  if vim.b[buf].nvim_undotree then
    local w = vim.b[buf].nvim_undotree
    if vim.api.nvim_win_is_valid(w) then
      vim.api.nvim_win_close(w, true)
      return true
    end
  elseif vim.b[buf].nvim_is_undotree then
    local w = vim.b[buf].nvim_is_undotree
    if vim.api.nvim_win_is_valid(w) then
      vim.api.nvim_win_close(w, true)
      return true
    end
  end

  local w = opts.winid
  if not w then
    vim.cmd(opts.command or '30vnew')
    w = vim.api.nvim_get_current_win()
  end

  local b = opts.bufnr
  if b then
    vim.api.nvim_win_set_buf(w, b)
  else
    b = vim.api.nvim_win_get_buf(w)
  end

  vim.b[buf].nvim_undotree = w
  vim.b[b].nvim_is_undotree = w

  local title --- @type string?
  local opts_title = opts.title
  if not opts_title then
    local bufname = vim.api.nvim_buf_get_name(buf)
    title = string.format('Undo tree for %s', vim.fn.fnamemodify(bufname, ':.'))
  elseif type(opts_title) == 'function' then
    title = opts_title(buf)
  elseif type(opts_title) == 'string' then
    title = opts_title
  end

  assert(type(title) == 'string', 'Window title must be a string')
  vim.api.nvim_buf_set_name(b, title)

  vim.wo[w][0].scrolloff = 5
  vim.wo[w][0].wrap = false
  vim.wo[w][0].foldmethod = 'manual'
  vim.wo[w][0].foldenable = true
  vim.wo[w][0].cursorline = true
  vim.bo[b].buflisted = false
  vim.bo[b].buftype = 'nofile'
  vim.bo[b].bufhidden = 'wipe'
  vim.bo[b].swapfile = false

  local meta = draw(buf, b)

  vim.api.nvim_win_set_cursor(w, { vim.api.nvim_buf_line_count(b), 0 })

  local group = vim.api.nvim_create_augroup('nvim.undotree', {})

  vim.api.nvim_win_call(w, function()
    vim.cmd.syntax('region Comment start="(" end=")"')
  end)

  vim.api.nvim_create_autocmd('CursorMoved', {
    group = group,
    buffer = b,
    callback = function()
      local row = vim.fn.line('.')
      vim.api.nvim_buf_call(buf, function()
        vim.cmd.undo { meta[row], mods = { silent = true } }
      end)
    end,
  })

  vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, {
    group = group,
    buffer = buf,
    callback = function()
      if not vim.api.nvim_buf_is_valid(b) then
        return true
      end

      meta = draw(buf, b)

      if vim.api.nvim_win_is_valid(w) then
        vim.wo[w][0].foldlevel = 99
      end
    end,
  })

  vim.bo[b].filetype = 'nvim-undotree'
end

return M
