#!/usr/bin/env -S nvim -l
-- Simple script to update the help doc by reading the config schema.

local inspect = vim.inspect
local list_extend = vim.list_extend
local startswith = vim.startswith

local config = require('lua.gitsigns.config')

-- To make sure the output is consistent between runs (to minimise diffs), we
-- need to iterate through the schema keys in a deterministic way. To do this we
-- do a smple scan over the file the schema is defined in and collect the keys
-- in the order they are defined.
--- @return string[]
local function get_ordered_schema_keys()
  local ci = io.lines('lua/gitsigns/config.lua') --- @type Iterator[string]

  for l in ci do
    if startswith(l, 'M.schema = {') then
      break
    end
  end

  local keys = {}
  for l in ci do
    if startswith(l, '}') then
      break
    end
    if l:find('^  (%w+).*') then
      local lc = l:gsub('^%s*([%w_]+).*', '%1')
      table.insert(keys, lc)
    end
  end

  return keys
end

--- @param dep_info boolean|{new_field: string, message: string, hard: boolean}
--- @param out fun(_: string?)
local function gen_config_doc_deprecated(dep_info, out)
  if type(dep_info) == 'table' and dep_info.hard then
    out('      HARD-DEPRECATED')
  else
    out('      DEPRECATED')
  end
  if type(dep_info) == 'table' then
    if dep_info.message then
      out('      ' .. dep_info.message)
    end
    if dep_info.new_field then
      out('')
      local opts_key, field = dep_info.new_field:match('(.*)%.(.*)')
      if opts_key and field then
        out(
          ('   Please instead use the field `%s` in |gitsigns-config-%s|.'):format(field, opts_key)
        )
      else
        out(('   Please instead use |gitsigns-config-%s|.'):format(dep_info.new_field))
      end
    end
  end
  out('')
end

--- @param field string
--- @param out fun(_: string?)
local function gen_config_doc_field(field, out)
  local v = config.schema[field]

  -- Field heading and tag
  local t = ('*gitsigns-config-%s*'):format(field)
  if #field + #t < 80 then
    out(('%-29s %48s'):format(field, t))
  else
    out(('%-29s'):format(field))
    out(('%78s'):format(t))
  end

  local deprecated = v.deprecated
  if deprecated then
    gen_config_doc_deprecated(deprecated, out)
  end

  if v.description then
    local d --- @type string
    local default_help = v.default_help
    if default_help ~= nil then
      d = default_help
    else
      d = inspect(v.default):gsub('\n', '\n    ')
      d = ('`%s`'):format(d)
    end

    local vtype = (function()
      local ty = v.type_help or v.type
      if ty == 'table' and v.deep_extend then
        return 'table[extended]'
      end
      if type(ty) == 'table' then
        v.type = table.concat(ty, '|')
      end
      return v.type
    end)()

    if d:find('\n') then
      out(('      Type: `%s`'):format(vtype))
      out('      Default: >')
      out('        ' .. d:gsub('\n([^\n\r])', '\n    %1'))
      out('<')
    else
      out(('      Type: `%s`, Default: %s'):format(vtype, d))
      out()
    end

    out(v.description:gsub(' +$', ''))
  end
end

--- @return string
local function gen_config_doc()
  local res = {} ---@type string[]

  local function out(line)
    res[#res + 1] = line or ''
  end

  for _, k in ipairs(get_ordered_schema_keys()) do
    gen_config_doc_field(k, out)
  end
  return table.concat(res, '\n')
end

--- @param line string
--- @return string
local function parse_func_header(line)
  -- match:
  --   prefix.name = ...
  --   function prefix.name(...
  local func = line:match('^%w+%.([%w_]+) =')
    or line:match('^function %w+%.([%w_]+)%(')
  if not func then
    error('Unable to parse: ' .. line)
  end
  local args_raw = line:match('function%((.*)%)') -- M.name = function(args)
    or line:match('function%s+%w+%.[%w_]+%((.*)%)') -- function M.name(args)
  local args = {} --- @type string[]
  for k in string.gmatch(args_raw, '([%w_]+)') do
    if k:sub(1, 1) ~= '_' then
      args[#args + 1] = string.format('{%s}', k)
    end
  end

  if line:match('async.create%(%d, function%(') then
    args[#args + 1] = '{callback?}'
  end

  return string.format(
    '%-40s%38s',
    string.format('%s(%s)', func, table.concat(args, ', ')),
    '*gitsigns.' .. func .. '()*'
  )
end

--- @param x string
--- @return string? name
--- @return string? type
--- @return string? description
local function parse_param(x)
  local name, ty, des = x:match('([^ ]+) +([^ ]+) *(.*)')
  return name, ty, des
end

--- @param x string[]
--- @return string[]
local function trim_lines(x)
  local min_pad --- @type integer?
  for _, e in ipairs(x) do
    local _, i = e:find('^ *')
    if not min_pad or min_pad > i then
      min_pad = i
    end
  end

  local r = {} --- @type string[]
  for _, e in ipairs(x) do
    r[#r + 1] = e:sub(min_pad + 1)
  end

  return r
end

--- @param name string
--- @param ty string
--- @param desc string[]
--- @param name_pad? integer
--- @return string[]
local function render_param_or_return(name, ty, desc, name_pad)
  ty = ty:gsub('Gitsigns%.%w+', 'table')

  name_pad = name_pad and (name_pad + 3) or 0
  local name_str --- @type string

  if name == ':' then
    name_str = ''
  else
    local nf = '%-' .. tostring(name_pad) .. 's'
    name_str = nf:format(string.format('{%s} ', name))
  end

  if #desc == 0 then
    return { string.format('    %s(%s)', name_str, ty) }
  end

  local r = {} --- @type string[]

  local desc1 = desc[1] == '' and '' or ' ' .. desc[1]
  r[#r + 1] = string.format('    %s(%s):%s', name_str, ty, desc1)

  local remain_desc = trim_lines(vim.list_slice(desc, 2))
  for _, d in ipairs(remain_desc) do
    r[#r + 1] = '    ' .. string.rep(' ', name_pad) .. d
  end

  return r
end

--- @param x string[]
--- @param amount integer
--- @return string[]
local function pad(x, amount)
  local pad_str = string.rep(' ', amount)

  local r = {} --- @type string[]
  for _, e in ipairs(x) do
    r[#r + 1] = pad_str .. e
  end
  return r
end

--- @param state EmmyState
--- @param doc_comment string
--- @param desc string[]
--- @param params [string, string, string[]][]
--- @param returns [string, string, string[]][]
--- @return EmmyState
local function process_doc_comment(state, doc_comment, desc, params, returns)
  if state == 'none' then
    state = 'in_block'
  end

  local emmy_type, emmy_str = doc_comment:match(' ?@([a-z]+) (.*)')

  if emmy_type == 'param' then
    local name, ty, pdesc = parse_param(emmy_str)
    params[#params + 1] = { name, ty, { pdesc } }
    return 'in_param'
  end

  if emmy_type == 'return' then
    local ty, name, rdesc = parse_param(emmy_str)
    returns[#returns + 1] = { name, ty, { rdesc } }
    return 'in_return'
  end

  if state == 'in_param' then
    -- Consume any remaining doc document lines as the description for the
    -- last parameter
    local lastdes = params[#params][3]
    lastdes[#lastdes + 1] = doc_comment
  elseif state == 'in_return' then
    -- Consume any remaining doc document lines as the description for the
    -- last return
    local lastdes = returns[#returns][3]
    lastdes[#lastdes + 1] = doc_comment
  else
    if doc_comment ~= '' and doc_comment ~= '<' then
      doc_comment = string.rep(' ', 16) .. doc_comment
    end
    desc[#desc + 1] = doc_comment
  end

  return state
end

--- @param header string
--- @param block string[]
--- @param params [string, string, string[]][]
--- @param returns [string, string, string[]][]
--- @param deprecated string?
--- @return string[]?
local function render_block(header, block, params, returns, deprecated)
  if vim.startswith(header, '_') then
    return
  end

  local res = { header }

  if deprecated then
    list_extend(res, {
      '                DEPRECATED: '..deprecated,
      ''
    })
  end

  list_extend(res, block)

  -- filter arguments beginning with '_'
  params = vim.tbl_filter(
    --- @param v [string, string, string[]]
    --- @return boolean
    function(v)
      return not startswith(v[1], '_')
    end,
    params
  )

  if #params > 0 then
    local param_block = { 'Parameters: ~' }

    local name_pad = 0
    for _, v in ipairs(params) do
      if #v[1] > name_pad then
        name_pad = #v[1]
      end
    end

    for _, v in ipairs(params) do
      local name, ty, desc = v[1], v[2], v[3]
      list_extend(param_block, render_param_or_return(name, ty, desc, name_pad))
    end
    list_extend(res, pad(param_block, 16))
  end

  if #returns > 0 then
    res[#res + 1] = ''
    local param_block = { 'Returns: ~' }
    for _, v in ipairs(returns) do
      local name, ty, desc = v[1], v[2], v[3]
      list_extend(param_block, render_param_or_return(name, ty, desc))
    end
    list_extend(res, pad(param_block, 16))
  end

  return res
end

--- @param path string
--- @return string
local function gen_functions_doc_from_file(path)
  local i = io.lines(path) --- @type Iterator[string]

  local blocks = {} --- @type string[][]

  --- @alias EmmyState 'none'|'in_block'|'in_param'|'in_return'
  local state = 'none' --- @type EmmyState
  local desc = {} --- @type string[]
  local params = {} --- @type [string, string, string[]][]
  local returns = {} --- @type [string, string, string[]][]
  local deprecated --- @type string?

  for l in i do
    local doc_comment = l:match('^%-%-%- ?(.*)') --- @type string?
    if doc_comment then
      local depre = doc_comment:match('@deprecated ?(.*)')
      if depre then
        deprecated = depre
      else
        state = process_doc_comment(state, doc_comment, desc, params, returns)
      end
    elseif state ~= 'none' then
      -- First line after block
      local ok, header = pcall(parse_func_header, l)
      if ok then
        blocks[#blocks + 1] = render_block(header, desc, params, returns, deprecated)
      end
      state = 'none'
      desc = {}
      params = {}
      returns = {}
      deprecated = nil
    end
  end

  local res = {} --- @type string[]
  for j = #blocks, 1, -1 do
    local b = blocks[j]
    for k = 1, #b do
      res[#res + 1] = b[k]:match('^ *$') and '' or b[k]
    end
    res[#res + 1] = ''
  end

  return table.concat(res, '\n')
end

--- @param files string[]
--- @return string
local function gen_functions_doc(files)
  local res = {} --- @type string[]
  for _, path in ipairs(files) do
    res[#res + 1] = gen_functions_doc_from_file(path)
  end
  return table.concat(res, '\n')
end

--- @return string
local function gen_highlights_doc()
  local res = {} --- @type string[]
  local highlights = require('lua.gitsigns.highlight')

  local name_max = 0
  for _, hl in ipairs(highlights.hls) do
    for name, _ in pairs(hl) do
      if name:len() > name_max then
        name_max = name:len()
      end
    end
  end

  for _, hl in ipairs(highlights.hls) do
    for name, spec in pairs(hl) do
      if not spec.hidden then
        local fallbacks_tbl = {} --- @type string[]
        for _, f in ipairs(spec) do
          fallbacks_tbl[#fallbacks_tbl + 1] = string.format('`%s`', f)
        end
        local fallbacks = table.concat(fallbacks_tbl, ', ')
        if spec.fg_factor then
          fallbacks = fallbacks .. (' (fg=%d%%)'):format(spec.fg_factor * 100)
        end
        res[#res + 1] = string.format('%s*hl-%s*', string.rep(' ', 56), name)
        res[#res + 1] = string.format('%s', name)
        if spec.desc then
          res[#res + 1] = string.format('%s%s', string.rep(' ', 8), spec.desc)
          res[#res + 1] = ''
        end
        res[#res + 1] = string.format('%sFallbacks: %s', string.rep(' ', 8), fallbacks)
      end
    end
  end

  return table.concat(res, '\n')
end

--- @return string
local function get_setup_from_readme()
  local readme = io.lines('README.md') --- @type Iterator[string]
  local res = {} --- @type string[]

  local function append(line)
    res[#res + 1] = line ~= '' and '    ' .. line or ''
  end

  for l in readme do
    if l:match("require%('gitsigns'%).setup {") then
      append(l)
      break
    end
  end

  for l in readme do
    append(l)
    if l == '}' then
      break
    end
  end

  return table.concat(res, '\n')
end

--- @param marker string
--- @return string|fun():string
local function get_marker_text(marker)
  return ({
    VERSION = 'v1.0.2', -- x-release-please-version
    CONFIG = gen_config_doc,
    FUNCTIONS = function()
      return gen_functions_doc({
        'lua/gitsigns.lua',
        'lua/gitsigns/actions.lua',
      })
    end,
    HIGHLIGHTS = gen_highlights_doc,
    SETUP = get_setup_from_readme,
  })[marker]
end

local function main()
  local template = io.lines('etc/doc_template.txt') --- @type Iterator[string]

  local out = assert(io.open('doc/gitsigns.txt', 'w'))

  for l in template do
    local marker = l:match('{{(.*)}}')
    if marker then
      local sub = get_marker_text(marker)
      if sub then
        if type(sub) == 'function' then
          sub = sub()
        end
        --- @type string
        sub = sub:gsub('%%', '%%%%')
        l = l:gsub('{{' .. marker .. '}}', sub)
      end
    end
    out:write(l or '', '\n')
  end
end

main()
