---@class snacks.spawn
local M = {}

local uv = vim.uv or vim.loop

---@class snacks.spawn.Config: uv.spawn.options,{}
---@field cmd string
---@field args? (string|number)[]
---@field timeout? number
---@field run? boolean
---@field debug? boolean
---@field on_stdout? fun(proc: snacks.spawn.Proc, data: string)
---@field on_stderr? fun(proc: snacks.spawn.Proc, data: string)
---@field on_exit? fun(proc: snacks.spawn.Proc, err: boolean)

---@class snacks.spawn.Multi: snacks.spawn.Config,{}
---@field cmd? nil
---@field on_exit? fun(procs: snacks.spawn.Proc[], err: boolean)

---@class snacks.spawn.Proc
---@field opts snacks.spawn.Config
---@field handle? uv.uv_process_t
---@field stdout uv.uv_pipe_t
---@field stderr uv.uv_pipe_t
---@field code? number
---@field signal? number
---@field timer? uv.uv_timer_t
---@field aborted? boolean
---@field data table<uv.uv_pipe_t, string[]>
local Proc = {}
Proc.__index = Proc

---@param handle uv.uv_handle_t?
local function close(handle)
  if handle and not handle:is_closing() then
    handle:close()
  end
end

---@param opts snacks.spawn.Config
function Proc.new(opts)
  local self = setmetatable({}, Proc)
  self.opts = opts
  self.code, self.signal = 0, 0
  self.data = {}
  if opts.run ~= false then
    self:run()
  end
  return self
end

function Proc:running()
  return self.handle and not self.handle:is_closing()
end

---@param signal? string|number
function Proc:kill(signal)
  close(self.stdout)
  close(self.stderr)
  if not self.handle then
    self.aborted = true
  elseif self:running() then
    self.handle:kill(signal or "sigterm")
  end
end

function Proc:failed()
  if self.aborted then
    return true
  end
  if self:running() then
    return false
  end
  return self.code ~= 0 or self.signal ~= 0
end

---@param opts? snacks.debug.cmd|{}
function Proc:debug(opts)
  ---@type snacks.debug.cmd
  opts = Snacks.config.merge({}, opts or {}, {
    cmd = self.opts.cmd,
    args = self.opts.args,
    cwd = self.opts.cwd,
  })
  opts.props = opts.props or {}
  if not self:running() then
    opts.props.code = ("`%d`"):format(self.code)
    opts.props.signal = ("`%d`"):format(self.signal)
    if self.aborted then
      opts.props.aborted = "`true`"
    end
  end
  if self:failed() then
    opts.level = "error"
  end
  local out = vim.trim(self:out() .. "\n" .. self:err())
  if out ~= "" then
    opts.footer = "# Output\n```\n" .. out .. "\n```"
  end
  return Snacks.debug.cmd(opts)
end

function Proc:run()
  assert(not self.handle, "already running")
  if self.aborted then
    return self:on_exit()
  end
  self.stdout = assert(uv.new_pipe())
  self.stderr = assert(uv.new_pipe())
  self.data = { [self.stdout] = {}, [self.stderr] = {} }
  if self.opts.debug then
    vim.schedule(function()
      self:debug()
    end)
  end
  local opts = vim.tbl_deep_extend("force", self.opts, {
    stdio = { nil, self.stdout, self.stderr },
    hide = true,
    args = vim.tbl_map(tostring, self.opts.args or {}),
  })
  self.handle = uv.spawn(self.opts.cmd, opts, function(code, signal)
    self.code = code
    self.signal = signal
    self:on_exit()
  end)
  if not self.handle then
    self.code = 1
    self.data[self.stderr] = { "Failed to spawn " .. self.opts.cmd }
    close(self.stdout)
    close(self.stderr)
    return self:on_exit()
  end
  if self.opts.timeout then
    self.timer = assert(uv.new_timer())
    self.timer:start(self.opts.timeout, 0, function()
      self:kill("sigterm")
    end)
  end
  for _, handle in ipairs({ self.stdout, self.stderr }) do
    handle:read_start(function(err, data)
      assert(not err, err)
      if data then
        self:on_data(data, handle)
      else
        close(handle)
      end
    end)
  end
end

function Proc:out()
  return table.concat(self.data[self.stdout] or {})
end

function Proc:err()
  return table.concat(self.data[self.stderr] or {})
end

function Proc:lines()
  return vim.split(self:out(), "\n", { plain = true })
end

---@param data string
---@param handle uv.uv_pipe_t
function Proc:on_data(data, handle)
  table.insert(self.data[handle], data)
  if self.opts.on_stdout and handle == self.stdout then
    self.opts.on_stdout(self, data)
  elseif self.opts.on_stderr and handle == self.stderr then
    self.opts.on_stderr(self, data)
  end
end

function Proc:on_exit()
  close(self.timer)
  close(self.handle)
  local check = assert(uv.new_check())
  check:start(function()
    for _, handle in ipairs({ self.stdout, self.stderr }) do
      if handle and not handle:is_closing() then
        return
      end
    end
    check:stop()
    close(check)
    close(self.stdout)
    close(self.stderr)
    if self.opts.on_exit then
      self.opts.on_exit(self, self.code ~= 0 or self.signal ~= 0 or self.aborted or false)
    end
  end)
end

---@param procs snacks.spawn.Proc[]
---@param opts? snacks.spawn.Multi
function M.multi(procs, opts)
  if #procs == 0 then
    return
  end
  opts = opts or {}
  local current = 0

  local function done()
    if opts.on_exit then
      opts.on_exit(procs, procs[current]:failed())
    end
  end

  local function next()
    current = current + 1
    assert(current <= #procs, "current > #procs")
    local proc = procs[current]
    proc.opts = Snacks.config.merge(vim.deepcopy(opts), proc.opts, {
      on_exit = function(_, err)
        if err or current == #procs then
          done()
        else
          next()
        end
      end,
    })
    proc:run()
  end

  ---@type snacks.spawn.Proc|{procs: snacks.spawn.Proc[]}
  local ret = setmetatable({
    procs = procs,
    run = next,
  }, {
    __index = function(_, k)
      return procs[current][k]
    end,
  })

  if opts.run ~= false then
    next()
  end
  return ret
end

M.new = Proc.new

return M
