local fn = vim.fn
local fmt = string.format

local async = require('pckr.async')
local pckr_plugins = require('pckr.plugin').plugins_by_name
local config = require('pckr.config')
local log = require('pckr.log')
local util = require('pckr.util')

local display = require('pckr.display')

--- @class Pckr.sync
local M = {}

--- @return Pckr.Display
local function open_display()
  return display.open({
    diff = async.sync(2, function(plugin, commit, __cb)
      local plugin_type = require('pckr.plugin_types')[plugin.type]
      return plugin_type.diff(plugin, commit)
    end),
  })
end

--- @param plugins table<string,Pckr.Plugin>
--- @return table<string,string>
local function find_extra_plugins(plugins)
  -- build a hashmap of all the plugins by their installation path
  local plugin_paths = {} --- @type table<string,Pckr.Plugin>
  for _, plugin in pairs(plugins) do
    local p = vim.uv.fs_realpath(plugin.install_path)
    if p then
      plugin_paths[p] = plugin
    end
  end

  local extra = {} --- @type table<string,string>

  for dir, dir_is_start in pairs({
    [config._opt_dir] = false,
    [config._start_dir] = true,
  }) do
    for name, ty in vim.fs.dir(dir) do
      if ty ~= 'file' then
        local path = vim.fs.joinpath(dir, name)
        local real_path = vim.uv.fs_realpath(path)
        if not real_path then
          -- Broken link
          assert(ty == 'link')
          extra[path] = name
        else
          local plugin = plugin_paths[real_path]
          if not plugin or dir_is_start ~= (plugin.start or false) then
            extra[real_path] = name
          end
        end
      end
    end
  end

  return extra
end

--- @async
--- @param task fun(plugin: Pckr.Plugin, disp: Pckr.Display, cb: fun(_:string?, _:string?))
--- @param plugins string[]
--- @param disp Pckr.Display
--- @param kind string
--- @return table<string, {err?: string}>
local function map_task(task, plugins, disp, kind)
  local tasks = {} --- @type (fun(cb: fun(_:string?, _:string?)))[]
  for _, v in ipairs(plugins) do
    local plugin = pckr_plugins[v]
    if not plugin then
      log.fmt_error('Unknown plugin: %s', v)
    else
      --- @param cb fun(_:string?, _:string?)
      tasks[#tasks + 1] = function(cb)
        task(plugin, disp, cb)
      end
    end
  end

  if #tasks == 0 then
    log.info('Nothing to do!')
    return {}
  end

  local limit = config.max_jobs and config.max_jobs or #tasks

  log.fmt_debug('Running tasks: %s', kind)
  if disp then
    disp:set_title(fmt('%s %d / %d plugins', kind, #tasks, #tasks))
  end

  --- @type [string?, string?][]
  local results = async.join(limit, tasks, function()
    return disp:check()
  end)

  local results1 = {} --- @type table<string,{err?: string}>
  for _, r in ipairs(results) do
    local name = r[1]
    if name then
      results1[name] = { err = r[2] }
    end
  end

  return results1
end

--- @async
--- @param plugin Pckr.Plugin
--- @param disp Pckr.Display
--- @return string?
local function post_update_hook(plugin, disp)
  if plugin.run or plugin.start then
    async.schedule()
    local loader = require('pckr.loader')
    loader.load_plugin(plugin, true)
  end

  if not plugin.run then
    return
  end

  async.schedule()

  local run_task = plugin.run

  if type(run_task) == 'function' then
    disp:item_update(plugin.name, 'running post update hook...')
    --- @type boolean, string?
    local ok, err = pcall(run_task, plugin, disp)
    if not ok then
      return 'Error running post update hook: ' .. err
    end
  elseif type(run_task) == 'string' then
    disp:item_update(plugin.name, fmt('running post update hook...("%s")', run_task))
    if vim.startswith(run_task, ':') then
      -- Run a vim command
      --- @type boolean, string?
      local ok, err = pcall(vim.cmd --[[@as function]], run_task:sub(2))
      if not ok then
        return 'Error running post update hook: ' .. err
      end
    else
      local jobs = require('pckr.jobs')
      local jr = jobs.run(run_task, { cwd = plugin.install_path })

      if jr.code ~= 0 then
        return fmt('Error running post update hook: %s', jr.stderr)
      end
    end
  end
end

--- @param plugin Pckr.Plugin
--- @param disp Pckr.Display
--- @return string, string?
local install_task = async.sync(2, function(plugin, disp)
  disp:item_start(plugin.name, 'installing...')

  local plugin_type = require('pckr.plugin_types')[plugin.type]

  local err = plugin_type.installer(plugin, disp)

  plugin.installed = fn.isdirectory(plugin.install_path) ~= 0

  if not err then
    err = post_update_hook(plugin, disp)
  end

  disp.items = disp.items or {}

  async.schedule()

  if not err then
    disp:item_succeeded(plugin.name, 'installed')
    log.fmt_debug('Installed %s', plugin.name)
  else
    disp:item_failed(plugin.name, 'failed to install', err)
    log.fmt_debug('Failed to install %s: %s', plugin.name, vim.inspect(err))
  end

  return plugin.name, err
end)

--- @param plugin Pckr.Plugin
--- @param disp Pckr.Display
--- @return string?, string?
local update_task = async.sync(2, function(plugin, disp)
  async.schedule()
  disp:item_start(plugin.name, 'updating...')

  if plugin.lock then
    disp:item_succeeded(plugin.name, 'locked')
    return
  end

  local plugin_type = require('pckr.plugin_types')[plugin.type]

  plugin.err = plugin_type.updater(plugin, disp)

  async.schedule()

  if plugin.err then
    disp:item_failed(plugin.name, 'failed to update', plugin.err)
    log.fmt_debug('Failed to update %s: %s', plugin.name, plugin.err)
    return plugin.name, plugin.err
  end

  local revs = plugin.revs

  if revs[1] == revs[2] then
    disp:item_done(plugin.name, 'up-to-date')
    return plugin.name
  end

  log.fmt_debug('Updated %s', plugin.name)
  plugin.err = post_update_hook(plugin, disp)

  if plugin.err then
    disp:item_failed(plugin.name, 'failed to run post update hook', plugin.err)
    log.fmt_debug('Failed to run post update hook %s: %s', plugin.name, plugin.err)
    return plugin.name, plugin.err
  end

  async.schedule()

  local info = {} --- @type string[]
  local ncommits = 0
  if plugin.messages then
    info[#info + 1] = 'Commits:'
    for _, m in ipairs(vim.split(plugin.messages, '\n')) do
      for _, line in ipairs(vim.split(m, '\n')) do
        info[#info + 1] = '    ' .. line
        ncommits = ncommits + 1
      end
    end

    info[#info + 1] = ''
  end

  disp:item_succeeded(plugin.name, fmt('updated: %d new commits', ncommits), info)

  return plugin.name
end)

--- @param dir string
--- @return boolean
local function helptags_stale(dir)
  local glob = fn.glob

  -- Adapted directly from minpac.vim
  local txts = glob(vim.fs.joinpath(dir, '*.txt'), true, true)
  vim.list_extend(txts, glob(vim.fs.joinpath(dir, '*.[a-z][a-z]x'), true, true))

  if #txts == 0 then
    return false
  end

  local tags = glob(vim.fs.joinpath(dir, 'tags'), true, true)
  vim.list_extend(tags, glob(vim.fs.joinpath(dir, 'tags-[a-z][a-z]'), true, true))

  if #tags == 0 then
    return true
  end

  ---@type integer
  local txt_newest = math.max(unpack(vim.tbl_map(fn.getftime, txts)))

  ---@type integer
  local tag_oldest = math.min(unpack(vim.tbl_map(fn.getftime, tags)))

  return txt_newest > tag_oldest
end

--- @param results table<string,{err?: string}>
local function update_helptags(results)
  local paths = {} --- @type string[]
  for plugin_name, r in pairs(results) do
    if not r.err then
      paths[#paths + 1] = pckr_plugins[plugin_name].install_path
    end
  end

  for _, dir in ipairs(paths) do
    local doc_dir = vim.fs.joinpath(dir, 'doc')
    if helptags_stale(doc_dir) then
      log.fmt_debug('Updating helptags for %s', doc_dir)
      vim.cmd('silent! helptags ' .. fn.fnameescape(doc_dir))
    end
  end
end

--- @return Pckr.Plugin
local function get_pckr_spec()
  local source = debug.getinfo(1, 'S').short_src
  assert(source:match('/lua/pckr/sync.lua$'))
  local pckr_loc = source:gsub('/lua/pckr/sync.lua', '')

  return {
    name = 'pckr.nvim',
    url = 'https://github.com/lewis6991/pckr.nvim.git',
    install_path = pckr_loc,
    type = 'local',
    revs = {},
    _dir = pckr_loc,
    _dep_only = false,
    simple = true,
  }
end

--- @async
--- Find and remove any plugins not currently configured for use
--- @param plugins? string[]
--- @param silent? boolean
--- @return table<string,true>? removed_plugins nil is operation was cancelled
function M.clean(plugins, silent)
  log.debug('Starting clean')

  local to_remove --- @type table<string,string>

  if plugins then
    to_remove = {} --- @type table<string,string>
    for _, p in ipairs(plugins) do
      if pckr_plugins[p] then
        to_remove[pckr_plugins[p].install_path] = p
      else
        log.fmt_error('Unknown plugin: %s', p)
      end
    end
  else
    to_remove = find_extra_plugins(pckr_plugins)
  end

  log.debug('extra plugins', to_remove)

  if not next(to_remove) then
    if not silent then
      log.info('No plugins to remove')
    else
      log.debug('No plugins to remove')
    end
    return {}
  end

  async.schedule()

  local lines = {} --- @type string[]
  for path, _ in pairs(to_remove) do
    lines[#lines + 1] = '  - ' .. path
  end

  if
    not config.autoremove
    and not display.ask_user('Removing the following directories. OK? (y/N)', lines)
  then
    log.warn('Cleaning cancelled!')
    return
  end

  local removed = {} --- @type table<string,true>

  for path, plugin_name in pairs(to_remove) do
    if fn.delete(path, 'rf') == -1 then
      pckr_plugins[plugin_name].installed = nil
      log.fmt_warn('Could not remove %s', path)
    else
      removed[plugin_name] = true
    end
  end

  return removed
end

--- @async
--- @param op 'sync'|'reinstall'|'install'|'update'|'upgrade'|'clean'
--- @param plugins? string[]
function M.sync(op, plugins)
  local do_sync = op == 'sync'
  local do_reinstall = op == 'reinstall'
  local do_clean = do_sync or do_reinstall or op == 'clean'
  local do_install = do_sync or do_reinstall or op == 'install'
  local do_update = do_sync or op == 'update'
  local do_upgrade = do_sync or op == 'upgrade'

  local removed = do_clean and M.clean(plugins, op ~= 'clean') or {}

  if not removed or do_reinstall and #removed == 0 then
    -- Clean operation was cancelled. Do not do anything else
    return
  end

  if not plugins or #plugins == 0 then
    plugins = vim.tbl_keys(pckr_plugins)
  end

  --- @type string[], string[]
  local to_install, to_update = {}, {}
  for _, plugin_name in pairs(plugins) do
    local plugin = pckr_plugins[plugin_name]
    if plugin then
      if do_update and plugin.installed then
        if plugin.installed then
          to_update[#to_update + 1] = plugin.name
        else
          log.fmt_debug('Plugin %s is not installed', plugin_name)
        end
      elseif
        do_install
        and not plugin.installed
        and (op ~= 'reinstall' or removed[plugin.name])
      then
        -- If operation is reinstall, do not install if plugin was not successfully
        -- removed.
        if plugin.installed then
          log.fmt_debug('Plugin %s is already installed', plugin_name)
        else
          to_install[#to_install + 1] = plugin.name
        end
      end
    else
      log.warn('Ignore non-existent plugin:', plugin_name)
    end
  end

  async.schedule()

  local disp = open_display()
  async.schedule()

  local delta = util.measure(function()
    if do_install and next(to_install) then
      log.debug('Gathering install tasks')
      local results = map_task(install_task, to_install, disp, 'installing')
      async.schedule()
      update_helptags(results)
    end

    if do_update and next(to_update) then
      log.debug('Gathering update tasks')
      local results = map_task(update_task, to_update, disp, 'updating')
      async.schedule()
      update_helptags(results)
    end

    if do_upgrade then
      pckr_plugins['pckr.nvim'] = get_pckr_spec()
      local results = map_task(update_task, { 'pckr.nvim' }, disp, 'updating')
      async.schedule()
      update_helptags(results)
    end
  end)

  disp:finish(delta / 1000)
end

return M
