--- @brief
---
--- Install, update, and delete external plugins. WARNING: It is still considered
--- experimental, yet should be stable enough for daily use.
---
---Manages plugins only in a dedicated [vim.pack-directory]() (see |packages|):
---`site/pack/core/opt` subdirectory of "data" |standard-path|. Subdirectory `site` of "data"
---standard path needs to be part of 'packpath'. It usually is, but might not be
---in cases like |--clean| or setting |$XDG_DATA_HOME| during startup.
---Plugin's subdirectory name matches plugin's name in specification.
---It is assumed that all plugins in the directory are managed exclusively by `vim.pack`.
---
---Uses Git to manage plugins and requires present `git` executable.
---Target plugins should be Git repositories with versions as named tags
---following semver convention `v<major>.<minor>.<patch>` (with or without `v` prefix).
---Like `v1.2.0` or `1.2.0`, but not `1.2` or `v1`.
---
---The latest state of all managed plugins is stored inside a [vim.pack-lockfile]()
---located at `$XDG_CONFIG_HOME/nvim/nvim-pack-lock.json`. It is a JSON file that
---is used to persistently track data about plugins.
---For a more robust config treat lockfile like its part: put under version control, etc.
---In this case all plugins from the lockfile will be installed at once and at lockfile's revision
---(instead of inferring from `version`). This is done on the very first `vim.pack` function call
---to ensure that lockfile is aligned with what is actually on the disk.
---Lockfile should not be edited by hand. Corrupted data for installed plugins is repaired
---(including after deleting whole file), but `version` fields will be missing
---for not yet added plugins.
---
---[vim.pack-examples]()
---
---Basic install and management ~
---
---- Add |vim.pack.add()| call(s) to 'init.lua':
---```lua
---
---vim.pack.add({
---   -- Install "plugin1" and use default branch (usually `main` or `master`)
---   'https://github.com/user/plugin1',
---
---   -- Same as above, but using a table (allows setting other options)
---   { src = 'https://github.com/user/plugin1' },
---
---   -- Specify plugin's name (here the plugin will be called "plugin2"
---   -- instead of "generic-name")
---   { src = 'https://github.com/user/generic-name', name = 'plugin2' },
---
---   -- Specify version to follow during install and update
---   {
---     src = 'https://github.com/user/plugin3',
---     -- Version constraint, see |vim.version.range()|
---     version = vim.version.range('1.0'),
---   },
---   {
---     src = 'https://github.com/user/plugin4',
---     -- Git branch, tag, or commit hash
---     version = 'main',
---   },
---})
---
----- Plugin's code can be used directly after `add()`
---plugin1 = require('plugin1')
---```
---
---- Restart Nvim (for example, with |:restart|). Plugins that were not yet
---installed will be available on disk after `add()` call. Their revision is
---taken from |vim.pack-lockfile| (if present) or inferred from the `version`.
---
---- To update all plugins with new changes:
---    - Execute |vim.pack.update()|. This will download updates from source and
---      show confirmation buffer in a separate tabpage.
---    - Review changes. To confirm all updates execute |:write|.
---      To discard updates execute |:quit|.
---    - (Optionally) |:restart| to start using code from updated plugins.
---
---Use shorter source ~
---
--- Create custom Lua helpers:
---
---```lua
---
---local gh = function(x) return 'https://github.com/' .. x end
---local cb = function(x) return 'https://codeberg.org/' .. x end
---vim.pack.add({ gh('user/plugin1'), cb('user/plugin2') })
---```
---
---Another approach is to utilize Git's `insteadOf` configuration:
---- `git config --global url."https://github.com/".insteadOf "gh:"`
---- `git config --global url."https://codeberg.org/".insteadOf "cb:"`
---- In 'init.lua': `vim.pack.add({ 'gh:user/plugin1', 'cb:user/plugin2' })`.
---  These sources will be used verbatim in |vim.pack-lockfile|, so reusing
---  the config on different machine will require the same Git configuration.
---
---Explore installed plugins ~
---
---- `vim.pack.update(nil, { offline = true })`
---- Navigate between plugins with `[[` and `]]`. List them with `gO`
---  (|vim.lsp.buf.document_symbol()|).
---
---Switch plugin's version and/or source ~
---
---- Update 'init.lua' for plugin to have desired `version` and/or `src`.
---  Let's say, the switch is for plugin named 'plugin1'.
---- |:restart|. The plugin's state on disk (revision and/or tracked source)
---  is not yet changed. Only plugin's `version` in |vim.pack-lockfile| is updated.
---- Execute `vim.pack.update({ 'plugin1' })`. The plugin's source is updated.
---  If only switching version, use `{ offline = true }` option table.
---- Review changes and either confirm or discard them. If discarded, revert
---  `version` change in 'init.lua' as well or you will be prompted again next time
---  you run |vim.pack.update()|.
---
---Freeze plugin from being updated ~
---
---- Update 'init.lua' for plugin to have `version` set to current revision.
---Get it from |vim.pack-lockfile| (plugin's field `rev`; looks like `abc12345`).
---- |:restart|.
---
---Unfreeze plugin to start receiving updates ~
---
---- Update 'init.lua' for plugin to have `version` set to whichever version
---you want it to be updated.
---- |:restart|.
---
---Revert plugin after an update ~
---
---- Revert the |vim.pack-lockfile| to the state before the update:
---    - If Git tracked: `git checkout HEAD -- nvim-pack-lock.json`
---    - If not tracked: examine log file ("nvim-pack.log" at "log" |standard-path|),
---      locate the revisions before the latest update, and (carefully) adjust
---      current lockfile to have those revisions.
---- |:restart|.
---- `vim.pack.update({ 'plugin' }, { offline = true, target = 'lockfile' })`.
---  Read and confirm.
---
---Synchronize config across machines ~
---
---- On main machine:
---     - Add |vim.pack-lockfile| to VCS.
---     - Push to the remote server.
---- On secondary machine:
---     - Pull from the server.
---     - |:restart|. New plugins (not present locally, but present in the lockfile)
---       are installed at proper revision. If some installation has failed but
---       you know it should not (like due to bad Internet connection),
---       revert |vim.pack-lockfile| and |:restart| again.
---     - `vim.pack.update(nil, { target = 'lockfile' })`. Read and confirm.
---     - Manually delete outdated plugins (present locally, but were not present
---       in the lockfile prior to restart) with `vim.pack.del( { 'plugin' })`.
---       They can be located by examining the VCS difference of the lockfile
---       (`git diff -- nvim-pack-lock.json` for Git).
---
---Remove plugins from disk ~
---
---- Remove plugin specs from |vim.pack.add()| calls in 'init.lua' or they will be
---  reinstalled later.
---- |:restart|.
---- Use |vim.pack.del()| with a list of plugin names to remove. Use |vim.pack.get()|
---  to get all non-active plugins:
---```lua
---vim.iter(vim.pack.get())
---  :filter(function(x) return not x.active end)
---  :map(function(x) return x.spec.name end)
---  :totable()
---```
---
---[vim.pack-events]()
---
---Performing actions via `vim.pack` functions can trigger these events:
---- [PackChangedPre]() - before trying to change plugin's state.
---- [PackChanged]() - after plugin's state has changed.
---
---The |event-data| has these keys (type: `vim.event.packchanged.data`):
---- `active` - whether plugin was added via |vim.pack.add()| to current session.
---- `kind` - one of "install" (install on disk; before loading),
---  "update" (update already installed plugin; might be not loaded),
---  "delete" (delete from disk).
---- `spec` - plugin's specification with defaults made explicit.
---- `path` - full path to plugin's directory.
---
---See `vim.event.packchanged.data` and `vim.event.packchangedpre.data`.
---
--- These events can be used to execute plugin hooks. For example:
---```lua
---local hooks = function(ev)
---   -- Use available |event-data|
---   local name, kind = ev.data.spec.name, ev.data.kind
---
---   -- Run build script after plugin's code has changed
---   if name == 'plug-1' and (kind == 'install' or kind == 'update') then
---     -- Append `:wait()` if you need synchronous execution
---     vim.system({ 'make' }, { cwd = ev.data.path })
---   end
---
---   -- If action relies on code from the plugin (like user command or
---   -- Lua code), make sure to explicitly load it first
---   if name == 'plug-2' and kind == 'update' then
---     if not ev.data.active then
---       vim.cmd.packadd('plug-2')
---     end
---     vim.cmd('PlugTwoUpdate')
---     require('plug2').after_update()
---   end
---end
---
----- If hooks need to run on install, run this before `vim.pack.add()`
----- To act on install from lockfile, run before very first `vim.pack.add()`
---vim.api.nvim_create_autocmd('PackChanged', { callback = hooks })
---```

local api = vim.api
local uv = vim.uv
local async = require('vim._async')

local M = {}

--- @class (private) vim.pack.LockData
--- @field rev string Latest recorded revision.
--- @field src string Plugin source.
--- @field version? string|vim.VersionRange Plugin `version`, as supplied in `spec`.

--- @class (private) vim.pack.Lock
--- @field plugins table<string, vim.pack.LockData> Map from plugin name to its lock data.

--- @type vim.pack.Lock
local plugin_lock

--- @return string
local function get_plug_dir()
  return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'core', 'opt')
end

local function lock_get_path()
  return vim.fs.joinpath(vim.fn.stdpath('config'), 'nvim-pack-lock.json')
end

-- Git ------------------------------------------------------------------------

--- @async
--- @param cmd string[]
--- @param cwd? string
--- @return string
local function git_cmd(cmd, cwd)
  -- Use '-c gc.auto=0' to disable `stderr` "Auto packing..." messages
  cmd = vim.list_extend({ 'git', '-c', 'gc.auto=0' }, cmd)
  local env = vim.fn.environ() --- @type table<string,string>
  env.GIT_DIR, env.GIT_WORK_TREE = nil, nil
  local sys_opts = { cwd = cwd, text = true, env = env, clear_env = true }
  local out = async.await(3, vim.system, cmd, sys_opts) --- @type vim.SystemCompleted
  async.await(1, vim.schedule)
  local stderr = out.stderr or ''
  if out.code ~= 0 then
    error(stderr)
  end
  if stderr ~= '' then
    vim.schedule(function()
      vim.notify(stderr:gsub('\n+$', ''), vim.log.levels.WARN)
    end)
  end
  return (assert(out.stdout):gsub('\n+$', ''))
end

local function parse_semver(x)
  return vim.version.parse(x, { strict = true })
end

--- @type vim.Version
local git_version

local function git_ensure_exec()
  local ok, sys = pcall(vim.system, { 'git', 'version' })
  if not ok then
    error('No `git` executable')
  end
  git_version = vim.version.parse(sys:wait().stdout) --[[@as vim.Version]]
end

--- @async
--- @param url string
--- @param path string
local function git_clone(url, path)
  local cmd = { 'clone', '--quiet', '--no-checkout' }

  if vim.startswith(url, 'file://') then
    cmd[#cmd + 1] = '--no-hardlinks'
  elseif git_version >= parse_semver('2.27.0') then
    cmd[#cmd + 1] = '--filter=blob:none'
  end

  vim.list_extend(cmd, { '--origin', 'origin', url, path })
  git_cmd(cmd, uv.cwd())
end

--- @async
--- @param ref string
--- @param cwd string
--- @return string
local function git_get_hash(ref, cwd)
  -- Using `rev-list -1` shows a commit of reference, while `rev-parse` shows
  -- hash of reference. Those are different for annotated tags.
  return git_cmd({ 'rev-list', '-1', ref }, cwd)
end

--- @async
--- @param cwd string
--- @return string
local function git_get_default_branch(cwd)
  local res = git_cmd({ 'rev-parse', '--abbrev-ref', 'origin/HEAD' }, cwd)
  return (res:gsub('^origin/', ''))
end

--- @async
--- @param cwd string
--- @return string[]
local function git_get_branches(cwd)
  local def_branch = git_get_default_branch(cwd)
  local cmd = { 'branch', '--remote', '--list', '--format=%(refname:short)', '--', 'origin/**' }
  local stdout = git_cmd(cmd, cwd)
  local res = {} --- @type string[]
  for l in vim.gsplit(stdout, '\n') do
    local branch = l:match('^origin/(.+)$')
    local pos = branch == def_branch and 1 or (#res + 1)
    table.insert(res, pos, branch)
  end
  return res
end

--- @async
--- @param cwd string
--- @return string[]
local function git_get_tags(cwd)
  local tags = git_cmd({ 'tag', '--list', '--sort=-v:refname' }, cwd)
  return tags == '' and {} or vim.split(tags, '\n')
end

-- Plugin operations ----------------------------------------------------------

--- @param msg string|string[]
--- @param level ('DEBUG'|'TRACE'|'INFO'|'WARN'|'ERROR')?
local function notify(msg, level)
  msg = type(msg) == 'table' and table.concat(msg, '\n') or msg
  vim.notify('vim.pack: ' .. msg, vim.log.levels[level or 'INFO'])
  vim.cmd.redraw()
end

--- @param x string|vim.VersionRange
--- @return boolean
local function is_version(x)
  return type(x) == 'string' or (type(x) == 'table' and pcall(x.has, x, '1'))
end

--- @param x string
--- @return boolean
local function is_semver(x)
  return parse_semver(x) ~= nil
end

local function is_nonempty_string(x)
  return type(x) == 'string' and x ~= ''
end

--- @return string
local function get_timestamp()
  return vim.fn.strftime('%Y-%m-%d %H:%M:%S')
end

--- @class vim.pack.Spec
---
--- URI from which to install and pull updates. Any format supported by `git clone` is allowed.
--- @field src string
---
--- Name of plugin. Will be used as directory name. Default: `src` repository name.
--- @field name? string
---
--- Version to use for install and updates. Can be:
--- - `nil` (no value, default) to use repository's default branch (usually `main` or `master`).
--- - String to use specific branch, tag, or commit hash.
--- - Output of |vim.version.range()| to install the greatest/last semver tag
---   inside the version constraint.
--- @field version? string|vim.VersionRange
---
--- @field data? any Arbitrary data associated with a plugin.

--- @alias vim.pack.SpecResolved { src: string, name: string, version: nil|string|vim.VersionRange, data: any|nil }

--- @param spec string|vim.pack.Spec
--- @return vim.pack.SpecResolved
local function normalize_spec(spec)
  spec = type(spec) == 'string' and { src = spec } or spec
  vim.validate('spec', spec, 'table')
  vim.validate('spec.src', spec.src, is_nonempty_string, false, 'non-empty string')
  local name = spec.name or spec.src:gsub('%.git$', '')
  name = (type(name) == 'string' and name or ''):match('[^/]+$') or ''
  vim.validate('spec.name', name, is_nonempty_string, true, 'non-empty string')
  vim.validate('spec.version', spec.version, is_version, true, 'string or vim.VersionRange')
  return { src = spec.src, name = name, version = spec.version, data = spec.data }
end

--- @class (private) vim.pack.PlugInfo
--- @field err string The latest error when working on plugin. If non-empty,
---   all further actions should not be done (including triggering events).
--- @field installed? boolean Whether plugin was successfully installed.
--- @field version_str? string `spec.version` with resolved version range.
--- @field version_ref? string Resolved version as Git reference (if different
---   from `version_str`).
--- @field sha_head? string Git hash of HEAD.
--- @field sha_target? string Git hash of `version_ref`.
--- @field update_details? string Details about the update:: changelog if HEAD
---   and target are different, available newer tags otherwise.

--- @class (private) vim.pack.Plug
--- @field spec vim.pack.SpecResolved
--- @field path string
--- @field info vim.pack.PlugInfo Gathered information about plugin.

--- @param spec string|vim.pack.Spec
--- @param plug_dir string?
--- @return vim.pack.Plug
local function new_plug(spec, plug_dir)
  local spec_resolved = normalize_spec(spec)
  local path = vim.fs.joinpath(plug_dir or get_plug_dir(), spec_resolved.name)
  local info = { err = '', installed = plugin_lock.plugins[spec_resolved.name] ~= nil }
  return { spec = spec_resolved, path = path, info = info }
end

--- Normalize plug array: gather non-conflicting data from duplicated entries.
--- @param plugs vim.pack.Plug[]
--- @return vim.pack.Plug[]
local function normalize_plugs(plugs)
  --- @type table<string, { plug: vim.pack.Plug, id: integer }>
  local plug_map = {}
  local n = 0
  for _, p in ipairs(plugs) do
    -- Collect
    if not plug_map[p.path] then
      n = n + 1
      plug_map[p.path] = { plug = p, id = n }
    end
    local p_data = plug_map[p.path]
    -- TODO(echasnovski): if both versions are `vim.VersionRange`, collect as
    -- their intersection. Needs `vim.version.intersect`.
    p_data.plug.spec.version = vim.F.if_nil(p_data.plug.spec.version, p.spec.version)

    -- Ensure no conflicts
    local spec_ref = p_data.plug.spec
    local spec = p.spec
    if spec_ref.src ~= spec.src then
      local src_1 = tostring(spec_ref.src)
      local src_2 = tostring(spec.src)
      error(('Conflicting `src` for `%s`:\n%s\n%s'):format(spec.name, src_1, src_2))
    end
    if spec_ref.version ~= spec.version then
      local ver_1 = tostring(spec_ref.version)
      local ver_2 = tostring(spec.version)
      error(('Conflicting `version` for `%s`:\n%s\n%s'):format(spec.name, ver_1, ver_2))
    end
  end

  --- @type vim.pack.Plug[]
  local res = {}
  for _, p_data in pairs(plug_map) do
    res[p_data.id] = p_data.plug
  end
  assert(#res == n)
  return res
end

--- @param names? string[]
--- @return vim.pack.Plug[]
local function plug_list_from_names(names)
  local p_data_list = M.get(names, { info = false })
  local plug_dir = get_plug_dir()
  local plugs = {} --- @type vim.pack.Plug[]
  for _, p_data in ipairs(p_data_list) do
    plugs[#plugs + 1] = new_plug(p_data.spec, plug_dir)
  end
  return plugs
end

--- Map from plugin path to its data.
--- Use map and not array to avoid linear lookup during startup.
--- @type table<string, { plug: vim.pack.Plug, id: integer }?>
local active_plugins = {}
local n_active_plugins = 0

--- @param p vim.pack.Plug
--- @param event_name 'PackChangedPre'|'PackChanged'
--- @param kind 'install'|'update'|'delete'
local function trigger_event(p, event_name, kind)
  local active = active_plugins[p.path] ~= nil
  local data = { active = active, kind = kind, spec = vim.deepcopy(p.spec), path = p.path }
  api.nvim_exec_autocmds(event_name, { pattern = p.path, data = data })
end

--- @param action string
--- @return fun(kind: 'begin'|'report'|'end', percent: integer, fmt: string, ...:any): nil
local function new_progress_report(action)
  local progress = { kind = 'progress', source = 'vim.pack', title = 'vim.pack' }
  local headless = #api.nvim_list_uis() == 0

  return vim.schedule_wrap(function(kind, percent, fmt, ...)
    progress.status = kind == 'end' and 'success' or 'running'
    progress.percent = percent
    local msg = ('%s %s'):format(action, fmt:format(...))
    progress.id = api.nvim_echo({ { msg } }, kind ~= 'report', progress)
    -- Force redraw to show installation progress during startup
    -- TODO: redraw! not needed with ui2.
    if not headless then
      vim.cmd.redraw({ bang = true })
    end
  end)
end

local copcall = package.loaded.jit and pcall or require('coxpcall').pcall

local function async_join_run_wait(funs)
  local n_threads = 2 * (uv.available_parallelism() or 1)
  --- @async
  local function joined_f()
    async.join(n_threads, funs)
  end
  async.run(joined_f):wait()
end

--- Execute function in parallel for each non-errored plugin in the list
--- @param plug_list vim.pack.Plug[]
--- @param f async fun(p: vim.pack.Plug)
--- @param progress_action string
local function run_list(plug_list, f, progress_action)
  local report_progress = new_progress_report(progress_action)

  -- Construct array of functions to execute in parallel
  local n_finished = 0
  local funs = {} --- @type (async fun())[]
  for _, p in ipairs(plug_list) do
    -- Run only for plugins which didn't error before
    if p.info.err == '' then
      --- @async
      funs[#funs + 1] = function()
        local ok, err = copcall(f, p) --[[@as string]]
        if not ok then
          p.info.err = err --- @as string
        end

        -- Show progress
        n_finished = n_finished + 1
        local percent = math.floor(100 * n_finished / #funs)
        report_progress('report', percent, '(%d/%d) - %s', n_finished, #funs, p.spec.name)
      end
    end
  end

  if #funs == 0 then
    return
  end

  -- Run async in parallel but wait for all to finish/timeout
  report_progress('begin', 0, '(0/%d)', #funs)
  async_join_run_wait(funs)
  report_progress('end', 100, '(%d/%d)', #funs, #funs)
end

local confirm_all = false

--- @param plug_list vim.pack.Plug[]
--- @return boolean
local function confirm_install(plug_list)
  if confirm_all then
    return true
  end

  -- Gather pretty aligned list of plugins to install
  local name_width, name_max_width = {}, 0 --- @type integer[], integer
  for i, p in ipairs(plug_list) do
    name_width[i] = api.nvim_strwidth(p.spec.name)
    name_max_width = math.max(name_max_width, name_width[i])
  end
  local lines = {} --- @type string[]
  for i, p in ipairs(plug_list) do
    local pad = (' '):rep(name_max_width - name_width[i] + 1)
    lines[i] = ('%s%sfrom %s'):format(p.spec.name, pad, p.spec.src)
  end

  local text = table.concat(lines, '\n')
  local confirm_msg = ('These plugins will be installed:\n\n%s\n'):format(text)
  local choice = vim.fn.confirm(confirm_msg, 'Proceed? &Yes\n&No\n&Always', 1, 'Question')
  confirm_all = choice == 3
  vim.cmd.redraw()
  return choice ~= 2
end

--- @param tags string[]
--- @param version_range vim.VersionRange
local function get_last_semver_tag(tags, version_range)
  local last_tag, last_ver_tag --- @type string, vim.Version
  for _, tag in ipairs(tags) do
    local ver_tag = parse_semver(tag)
    if ver_tag then
      if version_range:has(ver_tag) and (not last_ver_tag or ver_tag > last_ver_tag) then
        last_tag, last_ver_tag = tag, ver_tag
      end
    end
  end
  return last_tag
end

--- @async
--- @param p vim.pack.Plug
local function resolve_version(p)
  local function list_in_line(name, list)
    return ('\n%s: %s'):format(name, table.concat(list, ', '))
  end

  -- Resolve only once
  if p.info.version_str then
    return
  end
  local version = p.spec.version

  -- Default branch
  if not version then
    p.info.version_str = git_get_default_branch(p.path)
    p.info.version_ref = 'origin/' .. p.info.version_str
    return
  end

  -- Non-version-range like version: branch, tag, or commit hash
  local branches = git_get_branches(p.path)
  local tags = git_get_tags(p.path)
  if type(version) == 'string' then
    local is_branch = vim.tbl_contains(branches, version)
    local is_tag_or_hash = copcall(git_get_hash, version, p.path)
    if not (is_branch or is_tag_or_hash) then
      local err = ('`%s` is not a branch/tag/commit. Available:'):format(version)
        .. list_in_line('Tags', tags)
        .. list_in_line('Branches', branches)
      error(err)
    end

    p.info.version_str = version
    p.info.version_ref = (is_branch and 'origin/' or '') .. version
    return
  end
  --- @cast version vim.VersionRange

  -- Choose the greatest/last version among all matching semver tags
  p.info.version_str = get_last_semver_tag(tags, version)
  if p.info.version_str == nil then
    local semver_tags = vim.tbl_filter(is_semver, tags)
    table.sort(semver_tags, vim.version.gt)
    local err = 'No versions fit constraint. Relax it or switch to branch. Available:'
      .. list_in_line('Versions', semver_tags)
      .. list_in_line('Branches', branches)
    error(err)
  end
end

--- @async
--- @param p vim.pack.Plug
local function infer_revisions(p)
  p.info.sha_head = p.info.sha_head or git_get_hash('HEAD', p.path)

  resolve_version(p)
  local target_ref = p.info.version_ref or p.info.version_str --[[@as string]]
  p.info.sha_target = p.info.sha_target or git_get_hash(target_ref, p.path)
end

--- Keep repos in detached HEAD state. Infer commit from resolved version.
--- No local branches are created, branches from "origin" remote are used directly.
--- @async
--- @param p vim.pack.Plug
--- @param timestamp string
--- @param skip_stash? boolean
local function checkout(p, timestamp, skip_stash)
  infer_revisions(p)

  if not skip_stash then
    local stash_cmd = { 'stash' }
    if git_version > parse_semver('2.13.0') then
      -- Use 'push' to avoid a 'stash -m' bug in versions prior to git v2.26
      stash_cmd[#stash_cmd + 1] = 'push'
      stash_cmd[#stash_cmd + 1] = '--message'
      stash_cmd[#stash_cmd + 1] = ('vim.pack: %s Stash before checkout'):format(timestamp)
    end
    stash_cmd[#stash_cmd + 1] = '--quiet'
    git_cmd(stash_cmd, p.path)
  end

  git_cmd({ 'checkout', '--quiet', p.info.sha_target }, p.path)

  local submodule_cmd = { 'submodule', 'update', '--init', '--recursive' }
  if git_version >= parse_semver('2.36.0') then
    submodule_cmd[#submodule_cmd + 1] = '--filter=blob:none'
  end
  git_cmd(submodule_cmd, p.path)

  plugin_lock.plugins[p.spec.name].rev = p.info.sha_target

  -- (Re)Generate help tags according to the current help files.
  -- Also use `pcall()` because `:helptags` errors if there is no 'doc/'
  -- directory or if it is empty.
  local doc_dir = vim.fs.joinpath(p.path, 'doc')
  vim.fn.delete(vim.fs.joinpath(doc_dir, 'tags'))
  copcall(vim.cmd.helptags, { doc_dir, magic = { file = false } })
end

--- @param plug_list vim.pack.Plug[]
local function install_list(plug_list, confirm)
  local timestamp = get_timestamp()
  --- @async
  --- @param p vim.pack.Plug
  local function do_install(p)
    trigger_event(p, 'PackChangedPre', 'install')

    git_clone(p.spec.src, p.path)

    plugin_lock.plugins[p.spec.name].src = p.spec.src

    -- Prefer revision from the lockfile instead of using `version`
    p.info.sha_target = (plugin_lock.plugins[p.spec.name] or {}).rev

    checkout(p, timestamp, true)
    p.info.installed = true

    trigger_event(p, 'PackChanged', 'install')
  end

  -- Install possibly after user confirmation
  if not confirm or confirm_install(plug_list) then
    run_list(plug_list, do_install, 'Installing plugins')
  end

  -- Ensure that not fully installed plugins are absent on disk and in lockfile
  for _, p in ipairs(plug_list) do
    if not (p.info.installed and uv.fs_stat(p.path) ~= nil) then
      plugin_lock.plugins[p.spec.name] = nil
      vim.fs.rm(p.path, { recursive = true, force = true })
    end
  end
end

--- @async
--- @param p vim.pack.Plug
local function infer_update_details(p)
  p.info.update_details = ''
  infer_revisions(p)
  local sha_head = assert(p.info.sha_head)
  local sha_target = assert(p.info.sha_target)

  -- Try showing log of changes (if any)
  if sha_head ~= sha_target then
    local range = sha_head .. '...' .. sha_target
    local format = '--pretty=format:%m %h │ %s%d'
    -- Show only tags near commits (not `origin/main`, etc.)
    local decorate = '--decorate-refs=refs/tags'
    -- `--topo-order` makes showing divergent branches nicer, but by itself
    -- doesn't ensure that reverted ("left", shown with `<`) and added
    -- ("right", shown with `>`) commits have fixed order.
    local l = git_cmd({ 'log', format, '--topo-order', '--left-only', decorate, range }, p.path)
    local r = git_cmd({ 'log', format, '--topo-order', '--right-only', decorate, range }, p.path)
    p.info.update_details = l == '' and r or (r == '' and l or (l .. '\n' .. r))
    return
  end

  -- Suggest newer semver tags (i.e. greater than greatest past semver tag)
  local all_semver_tags = vim.tbl_filter(is_semver, git_get_tags(p.path))
  if #all_semver_tags == 0 then
    return
  end

  local older_tags = ''
  if git_version >= parse_semver('2.13.0') then
    older_tags = git_cmd({ 'tag', '--list', '--no-contains', sha_head }, p.path)
  end
  local cur_tags = git_cmd({ 'tag', '--list', '--points-at', sha_head }, p.path)
  local past_tags = vim.split(older_tags, '\n')
  vim.list_extend(past_tags, vim.split(cur_tags, '\n'))

  local any_version = vim.version.range('*') --[[@as vim.VersionRange]]
  local last_version = get_last_semver_tag(past_tags, any_version)

  local newer_semver_tags = vim.tbl_filter(function(x) --- @param x string
    return vim.version.gt(x, last_version)
  end, all_semver_tags)

  table.sort(newer_semver_tags, vim.version.gt)
  p.info.update_details = table.concat(newer_semver_tags, '\n')
end

--- @param plug vim.pack.Plug
--- @param load boolean|fun(plug_data: {spec: vim.pack.Spec, path: string})
local function pack_add(plug, load)
  -- Add plugin only once, i.e. no overriding of spec. This allows users to put
  -- plugin first to fully control its spec.
  if active_plugins[plug.path] then
    return
  end

  n_active_plugins = n_active_plugins + 1
  active_plugins[plug.path] = { plug = plug, id = n_active_plugins }

  if vim.is_callable(load) then
    load({ spec = vim.deepcopy(plug.spec), path = plug.path })
    return
  end

  -- NOTE: The `:packadd` specifically seems to not handle spaces in dir name
  vim.cmd.packadd({ vim.fn.escape(plug.spec.name, ' '), bang = not load, magic = { file = false } })

  -- The `:packadd` only sources plain 'plugin/' files. Execute 'after/' scripts
  -- if not during startup (when they will be sourced later, even if
  -- `vim.pack.add` is inside user's 'plugin/')
  -- See https://github.com/vim/vim/issues/15584
  -- Deliberately do so after executing all currently known 'plugin/' files.
  if vim.v.vim_did_enter == 1 and load then
    local after_paths = vim.fn.glob(plug.path .. '/after/plugin/**/*.{vim,lua}', false, true)
    --- @param path string
    vim.tbl_map(function(path)
      vim.cmd.source({ path, magic = { file = false } })
    end, after_paths)
  end
end

local function lock_write()
  -- Serialize `version`
  local lock = vim.deepcopy(plugin_lock)
  for _, l_data in pairs(lock.plugins) do
    local version = l_data.version
    if version then
      l_data.version = type(version) == 'string' and ("'%s'"):format(version) or tostring(version)
    end
  end

  local path = lock_get_path()
  vim.fn.mkdir(vim.fs.dirname(path), 'p')
  local fd = assert(uv.fs_open(path, 'w', 438))

  local data = vim.json.encode(lock, { indent = '  ', sort_keys = true })
  assert(uv.fs_write(fd, data .. '\n'))
  assert(uv.fs_close(fd))
end

--- @param names string[]
local function lock_repair(names, plug_dir)
  --- @async
  local function f()
    for _, name in ipairs(names) do
      local path = vim.fs.joinpath(plug_dir, name)
      -- Try reusing existing table to preserve maybe present `version`
      local data = plugin_lock.plugins[name] or {}
      data.rev = git_get_hash('HEAD', path)
      data.src = git_cmd({ 'remote', 'get-url', 'origin' }, path)
      plugin_lock.plugins[name] = data
    end
  end
  async.run(f):wait()
end

--- Sync lockfile data and installed plugins:
--- - Install plugins that have proper lockfile data but are not on disk.
--- - Repair corrupted lock data for installed plugins.
--- - Remove unrepairable corrupted lock data and plugins.
--- @param confirm boolean
--- @param specs vim.pack.Spec[] Plugin specs provided by the user. Can contain
--- fields outside of what is in the lockfile to be passed down to events.
local function lock_sync(confirm, specs)
  if type(plugin_lock.plugins) ~= 'table' then
    plugin_lock.plugins = {}
  end

  -- Compute installed plugins
  local plug_dir = get_plug_dir()
  if vim.uv.fs_stat(plug_dir) == nil then
    vim.fn.mkdir(plug_dir, 'p')
  end

  -- NOTE: The directory traversal is done on every startup, but it is very fast.
  -- Also, single `vim.fs.dir()` scales better than on demand `uv.fs_stat()` checks.
  local installed = {} --- @type table<string,string>
  for name, fs_type in vim.fs.dir(plug_dir) do
    installed[name] = fs_type
    plugin_lock.plugins[name] = plugin_lock.plugins[name] or {}
  end

  -- Traverse once optimizing for "regular startup" (no repair, no install)
  local to_install = {} --- @type vim.pack.Plug[]
  local to_repair = {} --- @type string[]
  local to_remove = {} --- @type string[]
  for name, data in pairs(plugin_lock.plugins) do
    if type(data) ~= 'table' then
      data = {} ---@diagnostic disable-line: missing-fields
      plugin_lock.plugins[name] = data
    end

    -- Deserialize `version`
    local version = data.version
    if type(version) == 'string' then
      data.version = version:match("^'(.+)'$") or vim.version.range(version)
    end

    -- Synchronize
    local is_bad_lock = type(data.rev) ~= 'string' or type(data.src) ~= 'string'
    local is_bad_plugin = installed[name] and installed[name] ~= 'directory'
    if is_bad_lock or is_bad_plugin then
      local t = installed[name] == 'directory' and to_repair or to_remove
      t[#t + 1] = name
    elseif not installed[name] then
      local spec ---@type vim.pack.Spec
      -- Try reusing spec from user's `vim.pack.add()` (matters for events)
      -- Delay until this point when shaving milliseconds shouldn't matter much
      for _, s in ipairs(specs) do
        local ok, s_norm = pcall(normalize_spec, s)
        if ok and s_norm.name == name then
          spec = vim.deepcopy(s_norm)
        end
      end

      -- Force fields relevant to actual installation, try to preserve others
      spec = spec or {}
      spec.src = data.src
      spec.name = name
      spec.version = spec.version or data.version

      to_install[#to_install + 1] = new_plug(spec, plug_dir)
    end
  end

  -- Perform actions if needed
  if #to_install > 0 then
    table.sort(to_install, function(a, b)
      return a.spec.name < b.spec.name
    end)
    git_ensure_exec()
    install_list(to_install, confirm)
    lock_write()
  end

  if #to_repair > 0 then
    lock_repair(to_repair, plug_dir)
    table.sort(to_repair)
    notify('Repaired corrupted lock data for plugins: ' .. table.concat(to_repair, ', '), 'WARN')
    lock_write()
  end

  if #to_remove > 0 then
    for _, name in ipairs(to_remove) do
      plugin_lock.plugins[name] = nil
      vim.fs.rm(vim.fs.joinpath(plug_dir, name), { recursive = true, force = true })
    end
    table.sort(to_remove)
    notify('Removed corrupted lock data for plugins: ' .. table.concat(to_remove, ', '), 'WARN')
    lock_write()
  end
end

local function lock_read(confirm, specs)
  if plugin_lock then
    return
  end

  local fd = uv.fs_open(lock_get_path(), 'r', 438)
  if fd then
    local stat = assert(uv.fs_fstat(fd))
    local data = assert(uv.fs_read(fd, stat.size, 0))
    assert(uv.fs_close(fd))
    plugin_lock = vim.json.decode(data)
  else
    plugin_lock = { plugins = {} }
  end

  lock_sync(vim.F.if_nil(confirm, true), vim.F.if_nil(specs, {}))
end

--- @class vim.pack.keyset.add
--- @inlinedoc
--- Load `plugin/` files and `ftdetect/` scripts. If `false`, works like `:packadd!`.
--- If function, called with plugin data and is fully responsible for loading plugin.
--- Default `false` during |init.lua| sourcing and `true` afterwards.
--- @field load? boolean|fun(plug_data: {spec: vim.pack.Spec, path: string})
---
--- @field confirm? boolean Whether to ask user to confirm initial install. Default `true`.

--- Add plugin to current session
---
--- - For each specification check that plugin exists on disk in |vim.pack-directory|:
---     - If exists, check if its `src` is the same as input. If not - delete
---       immediately to clean install from the new source. Otherwise do nothing.
---     - If doesn't exist, install it by downloading from `src` into `name`
---       subdirectory (via partial blobless `git clone`) and update revision
---       to match `version` (via `git checkout`). Plugin will not be on disk if
---       any step resulted in an error.
--- - For each plugin execute |:packadd| (or customizable `load` function) making
---   it reachable by Nvim.
---
--- Notes:
--- - Installation is done in parallel, but waits for all to finish before
---   continuing next code execution.
--- - If plugin is already present on disk, there are no checks about its current revision.
---   The specified `version` can be not the one actually present on disk.
---   Execute |vim.pack.update()| to synchronize.
--- - Adding plugin second and more times during single session does nothing:
---   only the data from the first adding is registered.
---
--- @param specs (string|vim.pack.Spec)[] List of plugin specifications. String item
--- is treated as `src`.
--- @param opts? vim.pack.keyset.add
function M.add(specs, opts)
  vim.validate('specs', specs, vim.islist, false, 'list')
  opts = vim.tbl_extend('force', { load = vim.v.vim_did_init == 1, confirm = true }, opts or {})
  vim.validate('opts', opts, 'table')

  lock_read(opts.confirm, specs)

  local plug_dir = get_plug_dir()
  local plugs = {} --- @type vim.pack.Plug[]
  for i = 1, #specs do
    plugs[i] = new_plug(specs[i], plug_dir)
  end
  plugs = normalize_plugs(plugs)

  -- Pre-process
  local plugs_to_install = {} --- @type vim.pack.Plug[]
  local needs_lock_write = false
  for _, p in ipairs(plugs) do
    -- Detect `version` change
    local p_lock = plugin_lock.plugins[p.spec.name] or {}
    needs_lock_write = needs_lock_write or p_lock.version ~= p.spec.version
    p_lock.version = p.spec.version
    plugin_lock.plugins[p.spec.name] = p_lock

    -- Register for install
    if not p.info.installed then
      plugs_to_install[#plugs_to_install + 1] = p
      needs_lock_write = true
    end
  end

  -- Install
  if #plugs_to_install > 0 then
    git_ensure_exec()
    install_list(plugs_to_install, opts.confirm)
  end

  if needs_lock_write then
    lock_write()
  end

  -- Register and load those actually on disk while collecting errors
  -- Delay showing all errors to have "good" plugins added first
  local errors = {} --- @type string[]
  for _, p in ipairs(plugs) do
    if p.info.installed then
      local ok, err = pcall(pack_add, p, opts.load) --[[@as string]]
      if not ok then
        p.info.err = err
      end
    end
    if p.info.err ~= '' then
      errors[#errors + 1] = ('`%s`:\n%s'):format(p.spec.name, p.info.err)
    end
  end

  if #errors > 0 then
    local error_str = table.concat(errors, '\n\n')
    error(('vim.pack:\n\n%s'):format(error_str))
  end
end

--- @param p vim.pack.Plug
--- @return string
local function compute_feedback_lines_single(p)
  local active_suffix = active_plugins[p.path] ~= nil and '' or ' (not active)'
  if p.info.err ~= '' then
    return ('## %s%s\n\n %s'):format(p.spec.name, active_suffix, p.info.err:gsub('\n', '\n  '))
  end

  local parts = { ('## %s%s\n'):format(p.spec.name, active_suffix) }
  local version_suffix = p.info.version_str == '' and '' or (' (%s)'):format(p.info.version_str)

  if p.info.sha_head == p.info.sha_target then
    parts[#parts + 1] = table.concat({
      'Path:     ' .. p.path,
      'Source:   ' .. p.spec.src,
      'Revision: ' .. p.info.sha_target .. version_suffix,
    }, '\n')

    if p.info.update_details ~= '' then
      local details = p.info.update_details:gsub('\n', '\n• ')
      parts[#parts + 1] = '\n\nAvailable newer versions:\n• ' .. details
    end
  else
    parts[#parts + 1] = table.concat({
      'Path:            ' .. p.path,
      'Source:          ' .. p.spec.src,
      'Revision before: ' .. p.info.sha_head,
      'Revision after:  ' .. p.info.sha_target .. version_suffix,
      '',
      'Pending updates:',
      p.info.update_details,
    }, '\n')
  end

  return table.concat(parts, '')
end

--- @param plug_list vim.pack.Plug[]
--- @param skip_same_sha boolean
--- @return string[]
local function compute_feedback_lines(plug_list, skip_same_sha)
  -- Construct plugin line groups for better report
  local report_err, report_update, report_same = {}, {}, {}
  for _, p in ipairs(plug_list) do
    --- @type string[]
    local group_arr = p.info.err ~= '' and report_err
      or (p.info.sha_head ~= p.info.sha_target and report_update or report_same)
    group_arr[#group_arr + 1] = compute_feedback_lines_single(p)
  end

  local lines = {}
  --- @param header string
  --- @param arr string[]
  local function append_report(header, arr)
    if #arr == 0 then
      return
    end
    header = header .. ' ' .. string.rep('─', 79 - header:len())
    table.insert(lines, header)
    vim.list_extend(lines, arr)
  end
  append_report('# Error', report_err)
  append_report('# Update', report_update)
  if not skip_same_sha then
    append_report('# Same', report_same)
  end

  return vim.split(table.concat(lines, '\n\n'), '\n')
end

--- @param plug_list vim.pack.Plug[]
local function feedback_log(plug_list)
  local lines = { ('========== Update %s =========='):format(get_timestamp()) }
  vim.list_extend(lines, compute_feedback_lines(plug_list, true))
  lines[#lines + 1] = ''

  local log_path = vim.fn.stdpath('log') .. '/nvim-pack.log'
  vim.fn.mkdir(vim.fs.dirname(log_path), 'p')
  vim.fn.writefile(lines, log_path, 'a')
end

--- @param lines string[]
--- @param on_finish fun(bufnr: integer)
local function show_confirm_buf(lines, on_finish)
  -- Show buffer in a separate tabpage
  local bufnr = api.nvim_create_buf(true, true)
  api.nvim_buf_set_name(bufnr, 'nvim-pack://confirm#' .. bufnr)
  api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
  vim.cmd.sbuffer({ bufnr, mods = { tab = vim.fn.tabpagenr() } })
  local win_id = api.nvim_get_current_win()

  local delete_buffer = vim.schedule_wrap(function()
    pcall(api.nvim_win_close, win_id, true)
    pcall(api.nvim_buf_delete, bufnr, { force = true })
    vim.cmd.redraw()
  end)

  -- Define action on accepting confirm
  local function finish()
    on_finish(bufnr)
    delete_buffer()
  end
  -- - Use `nested` to allow other events (useful for statuslines)
  api.nvim_create_autocmd('BufWriteCmd', { buf = bufnr, nested = true, callback = finish })

  -- Define action to cancel confirm
  --- @type integer
  local cancel_au_id
  local function on_cancel(data)
    if vim._tointeger(data.match) ~= win_id then
      return
    end
    pcall(api.nvim_del_autocmd, cancel_au_id)
    delete_buffer()
  end
  cancel_au_id = api.nvim_create_autocmd('WinClosed', { nested = true, callback = on_cancel })

  -- Set buffer-local options last (so that user autocmmands could override)
  vim.bo[bufnr].modified = false
  vim.bo[bufnr].modifiable = false
  vim.bo[bufnr].buftype = 'acwrite'
  vim.bo[bufnr].filetype = 'nvim-pack'

  -- Attach in-process LSP for more capabilities
  vim.lsp.buf_attach_client(bufnr, require('vim.pack._lsp').client_id)
end

--- Get map of plugin names that need update based on confirmation buffer
--- content: all plugin sections present in "# Update" section.
--- @param bufnr integer
--- @return table<string,boolean>
local function get_update_map(bufnr)
  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
  --- @type table<string,boolean>, boolean
  local res, is_in_update = {}, false
  for _, l in ipairs(lines) do
    local name = l:match('^## (.+)$')
    if name and is_in_update then
      res[name:gsub(' %(not active%)$', '')] = true
    end

    local group = l:match('^# (%S+)')
    if group then
      is_in_update = group == 'Update'
    end
  end
  return res
end

--- @class vim.pack.keyset.update
--- @inlinedoc
--- @field force? boolean Whether to skip confirmation and make updates immediately. Default `false`.
---
--- @field offline? boolean Whether to skip downloading new updates. Default: `false`.
---
--- How to compute a new plugin revision. One of:
---   - "version" (default): use latest revision matching `version` from plugin specification.
---   - "lockfile": use revision from the lockfile. For reverting or performing controlled update.
--- @field target? string

--- Update plugins
---
--- - Download new changes from source.
--- - Infer update info (current/target revisions, changelog, etc.).
--- - If `force` is `false` (default), show confirmation buffer.
---   If `force` is `true`, make updates right away.
---
--- Notes:
--- - Every actual update is logged in "nvim-pack.log" file inside "log" |standard-path|.
--- - It doesn't update source's default branch if it has changed (like from `master` to `main`).
---   To have `version = nil` point to a new default branch, re-install the plugin
---   (|vim.pack.del()| + |vim.pack.add()|).
---
--- Confirmation buffer ~
---
--- The goal of the confirmation buffer is to show update details for the user to read, confirm
--- (execute |:write|) or deny (execute |:quit|) the update.
---
--- Pending changes starting with `>` will be applied while the ones starting with `<` will be
--- reverted.
---
--- There are convenience buffer-local mappings:
--- - |]]| and |[[| to navigate through plugin sections.
---
--- Some features are provided via LSP:
--- - 'textDocument/documentSymbol' (`gO` via |lsp-defaults| or |vim.lsp.buf.document_symbol()|) -
---   show structure of the buffer.
--- - 'textDocument/hover' (`K` via |lsp-defaults| or |vim.lsp.buf.hover()|) - show more
---   information at cursor. Like details of particular pending change or newer tag.
--- - 'textDocument/codeAction' (`gra` via |lsp-defaults| or |vim.lsp.buf.code_action()|) - show
---   code actions relevant for "plugin at cursor". Like "delete" (if plugin is not active),
---   "update" or "skip updating" (if there are pending updates).
---
--- @param names? string[] List of plugin names to update. Must be managed
--- by |vim.pack|, not necessarily already added to current session.
--- Default: names of all plugins managed by |vim.pack|.
--- @param opts? vim.pack.keyset.update
function M.update(names, opts)
  vim.validate('names', names, vim.islist, true, 'list')
  opts = vim.tbl_extend('force', { force = false, offline = false, target = 'version' }, opts or {})

  local plug_list = plug_list_from_names(names)
  if #plug_list == 0 then
    notify('Nothing to update', 'WARN')
    return
  end
  git_ensure_exec()
  lock_read()

  -- Perform update
  local timestamp = get_timestamp()
  local needs_lock_write = opts.force --- @type boolean

  --- @async
  --- @param p vim.pack.Plug
  local function do_update(p)
    local l_data = plugin_lock.plugins[p.spec.name]
    -- Ensure proper `origin` if needed
    if l_data.src ~= p.spec.src then
      git_cmd({ 'remote', 'set-url', 'origin', p.spec.src }, p.path)
      plugin_lock.plugins[p.spec.name].src = p.spec.src
      needs_lock_write = true
    end

    -- Fetch
    if not opts.offline then
      -- Using '--tags --force' means conflicting tags will be synced with remote
      local args = { 'fetch', '--quiet', '--tags', '--force', '--recurse-submodules=yes', 'origin' }
      git_cmd(args, p.path)
    end

    -- Compute change info: changelog if any, new tags if nothing to update
    if opts.target == 'lockfile' then
      p.info.version_str = '*lockfile*'
      p.info.sha_target = l_data.rev
    end
    infer_update_details(p)

    -- Checkout immediately if no need to confirm
    if opts.force and p.info.sha_head ~= p.info.sha_target then
      trigger_event(p, 'PackChangedPre', 'update')
      checkout(p, timestamp)
      trigger_event(p, 'PackChanged', 'update')
    end
  end
  local progress_title = opts.force and (opts.offline and 'Applying updates' or 'Updating')
    or (opts.offline and 'Computing updates' or 'Downloading updates')
  run_list(plug_list, do_update, progress_title)

  if needs_lock_write then
    lock_write()
  end

  if opts.force then
    feedback_log(plug_list)
    return
  end

  -- Show report in new buffer in separate tabpage
  local lines = compute_feedback_lines(plug_list, false)
  show_confirm_buf(lines, function(bufnr)
    local to_update = get_update_map(bufnr)
    if not next(to_update) then
      notify('Nothing to update', 'WARN')
      return
    end

    --- @param p vim.pack.Plug
    local plugs_to_checkout = vim.tbl_filter(function(p)
      return to_update[p.spec.name]
    end, plug_list)

    local timestamp2 = get_timestamp()
    --- @async
    --- @param p vim.pack.Plug
    local function do_checkout(p)
      trigger_event(p, 'PackChangedPre', 'update')
      checkout(p, timestamp2)
      trigger_event(p, 'PackChanged', 'update')
    end
    run_list(plugs_to_checkout, do_checkout, 'Applying updates')

    lock_write()
    feedback_log(plugs_to_checkout)
  end)
end

--- @class vim.pack.keyset.del
--- @inlinedoc
--- @field force? boolean Whether to allow deleting an active plugin. Default `false`.

--- Remove plugins from disk
---
--- @param names string[] List of plugin names to remove from disk. Must be managed
--- by |vim.pack|, not necessarily already added to current session.
--- @param opts? vim.pack.keyset.del
function M.del(names, opts)
  vim.validate('names', names, vim.islist, false, 'list')
  opts = vim.tbl_extend('force', { force = false }, opts or {})

  local plug_list = plug_list_from_names(names)
  if #plug_list == 0 then
    notify('Nothing to remove', 'WARN')
    return
  end

  lock_read()

  local fail_to_delete = {} --- @type string[]
  for _, p in ipairs(plug_list) do
    if not active_plugins[p.path] or opts.force then
      trigger_event(p, 'PackChangedPre', 'delete')

      vim.fs.rm(p.path, { recursive = true, force = true })
      active_plugins[p.path] = nil
      notify(("Removed plugin '%s'"):format(p.spec.name), 'INFO')

      plugin_lock.plugins[p.spec.name] = nil

      trigger_event(p, 'PackChanged', 'delete')
    else
      fail_to_delete[#fail_to_delete + 1] = p.spec.name
    end
  end

  lock_write()

  if #fail_to_delete > 0 then
    local plugs = table.concat(fail_to_delete, ', ')
    local msg = ('Some plugins are active and were not deleted: %s.'):format(plugs)
      .. ' Remove them from init.lua, restart, and try again.'
    error(msg)
  end
end

--- @inlinedoc
--- @class vim.pack.PlugData
--- @field active boolean Whether plugin was added via |vim.pack.add()| to current session.
--- @field branches? string[] Available Git branches (first is default). Missing if `info=false`.
--- @field path string Plugin's path on disk.
--- @field rev string Current Git revision.
--- @field spec vim.pack.SpecResolved A |vim.pack.Spec| with resolved `name`.
--- @field tags? string[] Available Git tags. Missing if `info=false`.

--- @class vim.pack.keyset.get
--- @inlinedoc
--- @field info boolean Whether to include extra plugin info. Default `true`.

--- @param p_data_list vim.pack.PlugData[]
local function add_p_data_info(p_data_list)
  local funs = {} --- @type (async fun())[]
  for i, p_data in ipairs(p_data_list) do
    local path = p_data.path
    --- @async
    funs[i] = function()
      p_data.branches = git_get_branches(path)
      p_data.tags = git_get_tags(path)
    end
  end
  async_join_run_wait(funs)
end

--- Gets |vim.pack| plugin info, optionally filtered by `names`.
--- @param names? string[] List of plugin names. Default: all plugins managed by |vim.pack|.
--- @param opts? vim.pack.keyset.get
--- @return vim.pack.PlugData[]
function M.get(names, opts)
  vim.validate('names', names, vim.islist, true, 'list')
  opts = vim.tbl_extend('force', { info = true }, opts or {})

  -- Process active plugins in order they were added. Take into account that
  -- there might be "holes" after `vim.pack.del()`.
  local active = {} --- @type table<integer,vim.pack.Plug?>
  for _, p_active in pairs(active_plugins) do
    active[p_active.id] = p_active.plug
  end

  lock_read()
  local res = {} --- @type vim.pack.PlugData[]
  local used_names = {} --- @type table<string,boolean>
  for i = 1, n_active_plugins do
    if active[i] and (not names or vim.tbl_contains(names, active[i].spec.name)) then
      local name = active[i].spec.name
      local spec = vim.deepcopy(active[i].spec)
      local rev = (plugin_lock.plugins[name] or {}).rev
      res[#res + 1] = { spec = spec, path = active[i].path, rev = rev, active = true }
      used_names[name] = true
    end
  end

  local plug_dir = get_plug_dir()
  for name, l_data in vim.spairs(plugin_lock.plugins) do
    local path = vim.fs.joinpath(plug_dir, name)
    local is_in_names = not names or vim.tbl_contains(names, name)
    if not active_plugins[path] and is_in_names then
      local spec = { name = name, src = l_data.src, version = l_data.version }
      res[#res + 1] = { spec = spec, path = path, rev = l_data.rev, active = false }
      used_names[name] = true
    end
  end

  if names ~= nil then
    -- Align result with input
    local names_order = {} --- @type table<string,integer>
    for i, n in ipairs(names) do
      if not used_names[n] then
        error(('Plugin `%s` is not installed'):format(tostring(n)))
      end
      names_order[n] = i
    end
    table.sort(res, function(a, b)
      return names_order[a.spec.name] < names_order[b.spec.name]
    end)
  end

  if opts.info then
    git_ensure_exec()
    add_p_data_info(res)
  end

  return res
end

return M
