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

M.meta = {
  desc = "Scope detection, text objects and jumping based on treesitter or indent",
  needs_setup = true,
}

---@class snacks.scope.Opts: snacks.scope.Config,{}
---@field buf? number
---@field pos? {[1]:number, [2]:number} -- (1,0) indexed
---@field end_pos? {[1]:number, [2]:number} -- (1,0) indexed

---@class snacks.scope.TextObject: snacks.scope.Opts
---@field linewise? boolean if nil, use visual mode. Defaults to `false` when not in visual mode
---@field notify? boolean show a notification when no scope is found (defaults to true)

---@class snacks.scope.Jump: snacks.scope.Opts
---@field bottom? boolean if true, jump to the bottom of the scope, otherwise to the top
---@field notify? boolean show a notification when no scope is found (defaults to true)

---@alias snacks.scope.Attach.cb fun(win: number, buf: number, scope:snacks.scope.Scope?, prev:snacks.scope.Scope?)

---@class snacks.scope.Config
---@field max_size? number
---@field enabled? boolean
local defaults = {
  -- absolute minimum size of the scope.
  -- can be less if the scope is a top-level single line scope
  min_size = 2,
  -- try to expand the scope to this size
  max_size = nil,
  cursor = true, -- when true, the column of the cursor is used to determine the scope
  edge = true, -- include the edge of the scope (typically the line above and below with smaller indent)
  siblings = false, -- expand single line scopes with single line siblings
  -- what buffers to attach to
  filter = function(buf)
    return vim.bo[buf].buftype == "" and vim.b[buf].snacks_scope ~= false and vim.g.snacks_scope ~= false
  end,
  -- debounce scope detection in ms
  debounce = 30,
  treesitter = {
    -- detect scope based on treesitter.
    -- falls back to indent based detection if not available
    enabled = true,
    injections = true, -- include language injections when detecting scope (useful for languages like `vue`)
    ---@type string[]|{enabled?:boolean}
    blocks = {
      enabled = false, -- enable to use the following blocks
      "function_declaration",
      "function_definition",
      "method_declaration",
      "method_definition",
      "class_declaration",
      "class_definition",
      "do_statement",
      "while_statement",
      "repeat_statement",
      "if_statement",
      "for_statement",
    },
    -- these treesitter fields will be considered as blocks
    field_blocks = {
      "local_declaration",
    },
  },
  -- These keymaps will only be set if the `scope` plugin is enabled.
  -- Alternatively, you can set them manually in your config,
  -- using the `Snacks.scope.textobject` and `Snacks.scope.jump` functions.
  keys = {
    ---@type table<string, snacks.scope.TextObject|{desc?:string}>
    textobject = {
      ii = {
        min_size = 2, -- minimum size of the scope
        edge = false, -- inner scope
        cursor = false,
        treesitter = { blocks = { enabled = false } },
        desc = "inner scope",
      },
      ai = {
        cursor = false,
        min_size = 2, -- minimum size of the scope
        treesitter = { blocks = { enabled = false } },
        desc = "full scope",
      },
    },
    ---@type table<string, snacks.scope.Jump|{desc?:string}>
    jump = {
      ["[i"] = {
        min_size = 1, -- allow single line scopes
        bottom = false,
        cursor = false,
        edge = true,
        treesitter = { blocks = { enabled = false } },
        desc = "jump to top edge of scope",
      },
      ["]i"] = {
        min_size = 1, -- allow single line scopes
        bottom = true,
        cursor = false,
        edge = true,
        treesitter = { blocks = { enabled = false } },
        desc = "jump to bottom edge of scope",
      },
    },
  },
}

local id = 0

---@alias snacks.scope.scope {buf: number, from: number, to: number, indent?: number}

---@class snacks.scope.Scope
---@field buf number
---@field from number
---@field to number
---@field indent? number
---@field opts snacks.scope.Opts
local Scope = {}
Scope.__index = Scope

---@generic T: snacks.scope.Scope
---@param self T
---@param scope snacks.scope.scope
---@param opts snacks.scope.Opts
---@return T
function Scope:new(scope, opts)
  local ret = setmetatable(scope, { __index = self, __eq = self.__eq, __tostring = self.__tostring })
  ret.opts = opts
  return ret
end

function Scope:__eq(other)
  return other
    and self.buf == other.buf
    and self.from == other.from
    and self.to == other.to
    and self.indent == other.indent
end

---@generic T: snacks.scope.Scope
---@param self T
---@param opts snacks.scope.Opts
---@return T?
function Scope:find(opts)
  error("not implemented")
end

---@generic T: snacks.scope.Scope
---@param self T
---@return T?
function Scope:parent()
  error("not implemented")
end

---@generic T: snacks.scope.Scope
---@param self T
---@return T
function Scope:with_edge()
  error("not implemented")
end

---@generic T: snacks.scope.scope
---@param self T
---@return T
function Scope:inner()
  error("not implemented")
end

---@param line number
function Scope.get_indent(line)
  local ret = vim.fn.indent(line)
  return ret == -1 and nil or ret, line
end

---@generic T: snacks.scope.Scope
---@param self T
---@param opts {buf?: number, from?: number, to?: number, indent?: number}}
---@return T?
function Scope:with(opts)
  opts = vim.tbl_extend("keep", opts, self)
  return setmetatable(opts, getmetatable(self)) --[[ @as snacks.scope.Scope ]]
end

function Scope:size()
  return self.to - self.from + 1
end

function Scope:size_with_edge()
  return self:with_edge():size()
end

---@generic T: snacks.scope.Scope
---@param self T
---@return T?
function Scope:expand(line)
  local ret = self ---@type snacks.scope.Scope?
  while ret do
    if line >= ret.from and line <= ret.to then
      return ret
    end
    ret = ret:parent()
  end
end

---@class snacks.scope.IndentScope: snacks.scope.Scope
local IndentScope = setmetatable({}, Scope)
IndentScope.__index = IndentScope

---@param line number 1-indexed
---@param indent number
---@param up? boolean
function IndentScope._expand(line, indent, up)
  local next = up and vim.fn.prevnonblank or vim.fn.nextnonblank
  while line do
    local i, l = IndentScope.get_indent(next(line + (up and -1 or 1)))
    if (i or 0) == 0 or i < indent or l == 0 then
      return line
    end
    line = l
  end
  return line
end

-- Inner indent scope is all lines with higher indent than the current scope
function IndentScope:inner()
  local from, to, indent = nil, nil, math.huge
  for l = self.from, self.to do
    local i, il = IndentScope.get_indent(vim.fn.nextnonblank(l))
    if il == l then
      if i > self.indent then
        from = from or l
        to = l
        indent = math.min(indent, i)
      end
    end
  end
  return from and to and self:with({ from = from, to = to, indent = indent }) or self
end

function IndentScope:with_edge()
  if self.indent == 0 then
    return self
  end
  local before_i, before_l = Scope.get_indent(vim.fn.prevnonblank(self.from - 1))
  local after_i, after_l = Scope.get_indent(vim.fn.nextnonblank(self.to + 1))
  local indent = math.min(math.max(before_i or self.indent, after_i or self.indent), self.indent)
  local from = before_i and before_i == indent and before_l or self.from
  local to = after_i and after_i == indent and after_l or self.to
  if from == 0 or to == 0 or indent < 0 then
    return self
  end
  return self:with({ from = from, to = to, indent = indent })
end

---@param opts snacks.scope.Opts
function IndentScope:find(opts)
  local indent, line = Scope.get_indent(opts.pos[1])
  local prev_i, prev_l = Scope.get_indent(vim.fn.prevnonblank(line - 1))
  local next_i, next_l = Scope.get_indent(vim.fn.nextnonblank(line + 1))

  -- fix indent when line is empty
  if vim.fn.prevnonblank(line) ~= line then
    indent, line = Scope.get_indent(prev_i > next_i and prev_l or next_l)
    prev_i, prev_l = Scope.get_indent(vim.fn.prevnonblank(line - 1))
    next_i, next_l = Scope.get_indent(vim.fn.nextnonblank(line + 1))
  end

  if line == 0 then
    return
  end

  -- adjust line to the nearest indent block
  if prev_i <= indent and next_i > indent then
    -- at top edge
    line = next_l
    indent = next_i
  elseif next_i <= indent and prev_i > indent then
    -- at bottom edge
    line = prev_l
    indent = prev_i
  elseif next_i > indent and prev_i > indent then
    -- at edge of two blocks. Prefer the one below.
    line = next_l
    indent = next_i
  end

  if opts.cursor then
    indent = math.min(indent, vim.fn.virtcol(opts.pos) + 1)
  end

  -- expand to include bigger indents
  return IndentScope:new({
    buf = opts.buf,
    from = IndentScope._expand(line, indent, true),
    to = IndentScope._expand(line, indent, false),
    indent = indent,
  }, opts)
end

function IndentScope:parent()
  for i = self.indent - 1, 1, -1 do
    local u, d = IndentScope._expand(self.from, i, true), IndentScope._expand(self.to, i, false)
    if u ~= self.from or d ~= self.to then -- update only when expanded
      return self:with({ from = u, to = d, indent = i })
    end
  end
end

---@class snacks.scope.TSScope: snacks.scope.Scope
---@field node TSNode
local TSScope = setmetatable({}, Scope)
TSScope.__index = TSScope

-- Expand the scope to fill the range of the node
function TSScope:fill()
  local n = self.node
  local u, _, d = n:range()
  while n do
    local uu, _, dd = n:range()
    if uu == u and dd == d and not self:is_field(n) then
      self.node = n
    else
      break
    end
    n = n:parent()
  end
end

function TSScope:fix()
  self:fill()
  self.from, _, self.to = self.node:range()
  self.from, self.to = self.from + 1, self.to + 1
  self.indent = math.min(vim.fn.indent(self.from), vim.fn.indent(self.to))
  return self
end

---@param node? TSNode
function TSScope:is_field(node)
  node = node or self.node
  local parent = node:parent()
  parent = parent ~= node:tree():root() and parent or nil
  if not parent then
    return false
  end
  for child, field in parent:iter_children() do
    if child == node then
      return not (field == nil or vim.tbl_contains(self.opts.treesitter.field_blocks, field))
    end
  end
  error("node not found in parent")
end

function TSScope:with_edge()
  local ret = self ---@type snacks.scope.TSScope?
  while ret do
    if ret:size() >= 1 and not ret:is_field() then
      return ret
    end
    ret = ret:parent()
  end
  return self
end

function TSScope:root()
  if type(self.opts.treesitter.blocks) ~= "table" or not self.opts.treesitter.blocks.enabled then
    return self:fix()
  end
  local root = self.node --[[@as TSNode?]]
  while root do
    if vim.tbl_contains(self.opts.treesitter.blocks, root:type()) then
      return self:with({ node = root })
    end
    root = root:parent()
  end
  return self:fix()
end

---@param opts {buf?: number, from?: number, to?: number, indent?: number, node?: TSNode}}
function TSScope:with(opts)
  local ret = Scope.with(self, opts) --[[ @as snacks.scope.TSScope ]]
  return ret:fix()
end

---@param opts snacks.scope.Opts
function TSScope:parser(opts)
  local lang = vim.bo[opts.buf].filetype
  local has_parser, parser = pcall(vim.treesitter.get_parser, opts.buf, lang, { error = false })
  return has_parser and parser or nil
end

---@param cb fun()
---@param opts snacks.scope.Opts
function TSScope:init(cb, opts)
  local parser = self:parser(opts)
  if not parser then
    return cb()
  end
  Snacks.util.parse(parser, opts.treesitter.injections, cb)
end

---@param opts snacks.scope.Opts
function TSScope:find(opts)
  local lang = vim.treesitter.language.get_lang(vim.bo[opts.buf].filetype)
  local line = vim.fn.nextnonblank(opts.pos[1])
  line = line == 0 and vim.fn.prevnonblank(opts.pos[1]) or line
  -- FIXME:
  local pos = {
    math.max(line - 1, 0),
    (vim.fn.getline(line):find("%S") or 1) - 1, -- find first non-space character
  }

  local node = vim.treesitter.get_node({
    pos = pos,
    bufnr = opts.buf,
    lang = lang,
    ignore_injections = not opts.treesitter.injections,
  })
  if not node then
    return
  end

  if opts.cursor then
    -- expand to biggest ancestor with a lower start position
    local n = node ---@type TSNode?
    local virtcol = vim.fn.virtcol(opts.pos)
    while n and n ~= n:tree():root() do
      local r, c = n:range()
      local virtcol_n = vim.fn.virtcol({ r + 1, c })
      if virtcol_n > virtcol then
        node, n = n, n:parent()
      else
        break
      end
    end
  end

  local ret = TSScope:new({ buf = opts.buf, node = node }, opts):root()
  return ret
end

function TSScope:parent()
  local parent = self.node:parent()
  return parent and parent ~= self.node:tree():root() and self:with({ node = parent }):root() or nil
end

-- Inner treesitter scope includes all lines for which the node
-- has a start position lower than the start of the scope.
function TSScope:inner()
  local from, to, indent = nil, nil, math.huge
  for l = self.from + 1, self.to do
    if l == vim.fn.nextnonblank(l) then
      local col = (vim.fn.getline(l):find("%S") or 1) - 1
      local node = vim.treesitter.get_node({ pos = { l - 1, col }, bufnr = self.buf })
      local s = TSScope:new({ buf = self.buf, node = node }, self.opts):fix()
      if s and s.from > self.from and s.to <= self.to then
        from = from or l
        to = l
        indent = math.min(indent, vim.fn.indent(l))
      end
    end
  end
  return from and to and IndentScope:new({ from = from, to = to, indent = indent }, self.opts) or self
end

function Scope:__tostring()
  local meta = getmetatable(self)
  return ("%s(buf=%d, from=%d, to=%d, indent=%d)"):format(
    rawequal(meta, TSScope) and "TSScope" or rawequal(meta, IndentScope) and "IndentSCope" or "Scope",
    self.buf or -1,
    self.from or -1,
    self.to or -1,
    self.indent or 0
  )
end

---@param cb fun(scope?: snacks.scope.Scope)
---@param opts? snacks.scope.Opts|{parse?:boolean}
function M.get(cb, opts)
  opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.Opts ]]
  opts.buf = (opts.buf == nil or opts.buf == 0) and vim.api.nvim_get_current_buf() or opts.buf
  if not opts.pos then
    assert(opts.buf == vim.api.nvim_win_get_buf(0), "missing pos")
    opts.pos = vim.api.nvim_win_get_cursor(0)
  end

  -- run in the context of the buffer if not current
  if vim.api.nvim_get_current_buf() ~= opts.buf then
    vim.api.nvim_buf_call(opts.buf, function()
      M.get(cb, opts)
    end)
    return
  end

  ---@type snacks.scope.Scope
  local Class = (opts.treesitter.enabled and Snacks.util.get_lang(opts.buf)) and TSScope or IndentScope
  if rawequal(Class, TSScope) and opts.parse ~= false then
    TSScope:init(function()
      opts.parse = false
      M.get(cb, opts)
    end, opts)
    return
  end
  local scope = Class:find(opts) --[[ @as snacks.scope.Scope? ]]

  -- fallback to indent based detection
  if not scope and rawequal(Class, TSScope) then
    Class = IndentScope
    scope = Class:find(opts)
  end

  -- when end_pos is provided, get its scope and expand the current scope
  -- to include it.
  if scope and opts.end_pos and not vim.deep_equal(opts.pos, opts.end_pos) then
    local end_scope = Class:find(vim.tbl_extend("keep", { pos = opts.end_pos }, opts)) --[[ @as snacks.scope.Scope? ]]
    if end_scope and end_scope.from < scope.from then
      scope = scope:expand(end_scope.from) or scope
    end
    if end_scope and end_scope.to > scope.to then
      scope = scope:expand(end_scope.to) or scope
    end
  end

  local min_size = opts.min_size or 2
  local max_size = opts.max_size or min_size

  -- expand block with ancestors until min_size is reached
  -- or max_size is reached
  if scope then
    local s = scope --- @type snacks.scope.Scope?
    while s do
      if opts.edge and scope:size_with_edge() >= min_size and s:size_with_edge() > max_size then
        break
      elseif not opts.edge and scope:size() >= min_size and s:size() > max_size then
        break
      end
      scope, s = s, s:parent()
    end
    -- expand with edge
    if opts.edge then
      scope = scope:with_edge() --[[@as snacks.scope.Scope]]
    end
  end

  -- expand single line blocks with single line siblings
  if opts.siblings and scope and scope:size() == 1 then
    while scope and scope:size() < min_size do
      local prev, next = vim.fn.prevnonblank(scope.from - 1), vim.fn.nextnonblank(scope.to + 1) ---@type number, number
      local prev_dist, next_dist = math.abs(opts.pos[1] - prev), math.abs(opts.pos[1] - next)
      local prev_s = prev > 0 and Class:find(vim.tbl_extend("keep", { pos = { prev, 0 } }, opts))
      local next_s = next > 0 and Class:find(vim.tbl_extend("keep", { pos = { next, 0 } }, opts))
      prev_s = prev_s and prev_s:size() == 1 and prev_s
      next_s = next_s and next_s:size() == 1 and next_s
      local s = prev_dist < next_dist and prev_s or next_s or prev_s
      if s and (s.from < scope.from or s.to > scope.to) then
        scope = Scope.with(scope, { from = math.min(scope.from, s.from), to = math.max(scope.to, s.to) })
      else
        break
      end
    end
  end
  cb(scope)
end

---@class snacks.scope.Listener
---@field id integer
---@field cb snacks.scope.Attach.cb
---@field opts snacks.scope.Config
---@field dirty table<number, boolean>
---@field timer uv.uv_timer_t
---@field augroup integer
---@field enabled boolean
---@field active table<number, snacks.scope.Scope>
local Listener = {}

---@param cb snacks.scope.Attach.cb
---@param opts? snacks.scope.Config
function Listener.new(cb, opts)
  local self = setmetatable({}, { __index = Listener })
  self.cb = cb
  self.dirty = {}
  self.timer = assert((vim.uv or vim.loop).new_timer())
  self.enabled = false
  self.opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.Opts ]]
  id = id + 1
  self.id = id
  self.active = {}
  return self
end

--- Check if the scope has changed in the window / buffer
function Listener:check(win)
  local buf = vim.api.nvim_win_get_buf(win)
  if not self.opts.filter(buf) then
    if self.active[win] then
      local prev = self.active[win]
      self.active[win] = nil
      self.cb(win, buf, nil, prev)
    end
    return
  end

  M.get(
    function(scope)
      local prev = self.active[win]
      if prev == scope then
        return -- no change
      end
      self.active[win] = scope
      self.cb(win, buf, scope, prev)
    end,
    vim.tbl_extend("keep", {
      buf = buf,
      pos = vim.api.nvim_win_get_cursor(win),
    }, self.opts)
  )
end

--- Get the active scope for a window
function Listener:get(win)
  local scope = self.active[win]
  return scope and vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == scope.buf and scope or nil
end

--- Cleanup invalid scopes
function Listener:clean()
  for win in pairs(self.active) do
    self.active[win] = self:get(win)
  end
end

--- Iterate over active scopes
function Listener:iter()
  self:clean()
  return pairs(self.active)
end

--- Schedule a scope update
---@param wins? number|number[]
---@param opts? {now?: boolean}
function Listener:update(wins, opts)
  wins = type(wins) == "number" and { wins } or wins or vim.api.nvim_list_wins() --[[ @as number[] ]]
  for _, b in ipairs(wins) do
    self.dirty[b] = true
  end
  local function update()
    self:_update()
  end
  if opts and opts.now then
    update()
  end
  self.timer:start(self.opts.debounce, 0, vim.schedule_wrap(update))
end

--- Process all pending updates
function Listener:_update()
  for win in pairs(self.dirty) do
    if vim.api.nvim_win_is_valid(win) then
      self:check(win)
    end
  end
  self.dirty = {}
end

--- Start listening for scope changes
function Listener:enable()
  assert(not self.enabled, "already enabled")
  self.enabled = true
  self.augroup = vim.api.nvim_create_augroup("snacks_scope_" .. self.id, { clear = true })
  vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, {
    group = self.augroup,
    callback = function(ev)
      for _, win in ipairs(vim.fn.win_findbuf(ev.buf)) do
        self:update(win)
      end
    end,
  })
  vim.api.nvim_create_autocmd({ "WinClosed", "BufDelete", "BufWipeout" }, {
    group = self.augroup,
    callback = function()
      self:clean()
    end,
  })
  self:update(nil, { now = true })
end

--- Stop listening for scope changes
function Listener:disable()
  assert(self.enabled, "already disabled")
  self.enabled = false
  vim.api.nvim_del_augroup_by_id(self.augroup)
  self.timer:stop()
  self.active = {}
  self.dirty = {}
end

--- Attach a scope listener
---@param cb snacks.scope.Attach.cb
---@param opts? snacks.scope.Config
---@return snacks.scope.Listener
function M.attach(cb, opts)
  local ret = Listener.new(cb, opts)
  ret:enable()
  return ret
end

-- Text objects for indent scopes.
-- Best to use with Treesitter disabled.
-- When in visual mode, it will select the scope containing the visual selection.
-- When the scope is the same as the visual selection, it will select the parent scope instead.
---@param opts? snacks.scope.TextObject
function M.textobject(opts)
  opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.TextObject ]]

  local mode = vim.fn.mode()
  local selection = mode:find("[vV]") ~= nil

  -- prepare for visual mode and determine linewise
  if mode == "v" then
    vim.cmd("normal! v")
  elseif mode == "V" then
    vim.cmd("normal! V")
    opts.linewise = opts.linewise == nil and true or opts.linewise
  end

  -- use the actual range instead of the cursor position
  -- in case of visual mode
  if selection then
    opts.pos = vim.api.nvim_buf_get_mark(0, "<")
    opts.end_pos = vim.api.nvim_buf_get_mark(0, ">")
  end
  local inner = not opts.edge
  opts.edge = true -- always include the edge of the scope to make inner work

  M.get(function(scope)
    if not scope then
      return opts.notify ~= false and Snacks.notify.warn("No scope in range")
    end

    scope = inner and scope:inner() or scope
    -- determine scope range
    local from, to =
      { scope.from, opts.linewise and 0 or vim.fn.indent(scope.from) },
      { scope.to, opts.linewise and 0 or vim.fn.col({ scope.to, "$" }) - 2 }

    -- select the range
    vim.api.nvim_win_set_cursor(0, from)
    vim.cmd("normal! " .. (opts.linewise and "V" or "v"))
    vim.api.nvim_win_set_cursor(0, to)
  end, opts)
end

--- Jump to the top or bottom of the scope
--- If the scope is the same as the current scope, it will jump to the parent scope instead.
---@param opts? snacks.scope.Jump
function M.jump(opts)
  opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.Jump ]]
  M.get(function(scope)
    if not scope then
      return opts.notify ~= false and Snacks.notify.warn("No scope in range")
    end
    while scope do
      local line = opts.bottom and scope.to or scope.from
      local pos = { line, vim.fn.indent(line) }
      if not vim.deep_equal(vim.api.nvim_win_get_cursor(0), pos) then
        return vim.api.nvim_win_set_cursor(0, { line, vim.fn.indent(line) })
      end
      scope = scope:parent()
    end
  end, opts)
end

---@private
function M.setup()
  local keys = Snacks.config.get("scope", defaults).keys
  for key, opts in pairs(keys.textobject) do
    vim.keymap.set({ "x", "o" }, key, function()
      M.textobject(opts)
    end, { silent = true, desc = opts.desc })
  end
  for key, opts in pairs(keys.jump) do
    vim.keymap.set({ "n", "x", "o" }, key, function()
      M.jump(opts)
    end, { silent = true, desc = opts.desc })
  end
end

M.TSScope = TSScope
M.IdentScope = IndentScope

return M
