local PickerName = require("obsidian.config").Picker

local M = {}

--- Get the default Picker.
---
---@param picker_name obsidian.config.Picker|?
---
---@return obsidian.Picker|?
M.get =

function(picker_name)
  picker_name = picker_name and picker_name or Obsidian.opts.picker.name
  if picker_name then
    picker_name = string.lower(picker_name)
  else
    for _, name in ipairs { PickerName.telescope, PickerName.fzf_lua, PickerName.mini, PickerName.snacks } do
      local ok, res = pcall(M.get, name)
      if ok then
        return res
      end
    end
    return nil
  end
  if picker_name == string.lower(PickerName.telescope) then
    return require("obsidian.pickers._telescope").new()
  elseif picker_name == string.lower(PickerName.mini) then
    return require("obsidian.pickers._mini").new()
  elseif picker_name == string.lower(PickerName.fzf_lua) then
    return require("obsidian.pickers._fzf").new()
  elseif picker_name == string.lower(PickerName.snacks) then
    return require("obsidian.pickers._snacks").new()
  elseif picker_name then
    error("not implemented for " .. picker_name)
  end
end

local telescope = require "telescope.builtin"
local telescope_actions = require "telescope.actions"
local actions_state = require "telescope.actions.state"

local Path = require "obsidian.path"
local abc = require "obsidian.abc"
local Picker = require "obsidian.pickers.picker"
local log = require "obsidian.log"

---@class obsidian.pickers.TelescopePicker : obsidian.Picker
local TelescopePicker = abc.new_class({
  ---@diagnostic disable-next-line: unused-local
  __tostring = function(self)
    return "TelescopePicker()"
  end,
}, Picker)

---@param prompt_bufnr integer
---@param keep_open boolean|?
---@return table|?
local function get_entry(prompt_bufnr, keep_open)
  local entry = actions_state.get_selected_entry()
  if entry and not keep_open then
    telescope_actions.close(prompt_bufnr)
  end
  return entry
end

---@param prompt_bufnr integer
---@param keep_open boolean|?
---@param allow_multiple boolean|?
---@return table[]|?
local function get_selected(prompt_bufnr, keep_open, allow_multiple)
  local picker = actions_state.get_current_picker(prompt_bufnr)
  local entries = picker:get_multi_selection()
  if entries and #entries > 0 then
    if #entries > 1 and not allow_multiple then
      log.err "This mapping does not allow multiple entries"
      return
    end

    if not keep_open then
      telescope_actions.close(prompt_bufnr)
    end

    return entries
  else
    local entry = get_entry(prompt_bufnr, keep_open)

    if entry then
      return { entry }
    end
  end
end

---@param prompt_bufnr integer
---@param keep_open boolean|?
---@param initial_query string|?
---@return string|?
local function get_query(prompt_bufnr, keep_open, initial_query)
  local query = actions_state.get_current_line()
  if not query or string.len(query) == 0 then
    query = initial_query
  end
  if query and string.len(query) > 0 then
    if not keep_open then
      telescope_actions.close(prompt_bufnr)
    end
    return query
  else
    return nil
  end
end

---@param opts { entry_key: string|?, callback: fun(path: string)|?, allow_multiple: boolean|?, query_mappings: obsidian.PickerMappingTable|?, selection_mappings: obsidian.PickerMappingTable|?, initial_query: string|? }
local function attach_picker_mappings(map, opts)
  -- Docs for telescope actions:
  -- https://github.com/nvim-telescope/telescope.nvim/blob/master/lua/telescope/actions/init.lua

  local function entry_to_value(entry)
    if opts.entry_key then
      return entry[opts.entry_key]
    else
      return entry
    end
  end

  if opts.query_mappings then
    for key, mapping in pairs(opts.query_mappings) do
      map({ "i", "n" }, key, function(prompt_bufnr)
        local query = get_query(prompt_bufnr, false, opts.initial_query)
        if query then
          mapping.callback(query)
        end
      end)
    end
  end

  if opts.selection_mappings then
    for key, mapping in pairs(opts.selection_mappings) do
      map({ "i", "n" }, key, function(prompt_bufnr)
        local entries = get_selected(prompt_bufnr, mapping.keep_open, mapping.allow_multiple)
        if entries then
          local values = vim.tbl_map(entry_to_value, entries)
          mapping.callback(unpack(values))
        elseif mapping.fallback_to_query then
          local query = get_query(prompt_bufnr, mapping.keep_open)
          if query then
            mapping.callback(query)
          end
        end
      end)
    end
  end

  if opts.callback then
    map({ "i", "n" }, "<CR>", function(prompt_bufnr)
      local entries = get_selected(prompt_bufnr, false, opts.allow_multiple)
      if entries then
        local values = vim.tbl_map(entry_to_value, entries)
        opts.callback(unpack(values))
      end
    end)
  end
end

---@param opts obsidian.PickerFindOpts|? Options.
TelescopePicker.find_files = function(self, opts)
  opts = opts or {}

  local prompt_title = self:_build_prompt {
    prompt_title = opts.prompt_title,
    query_mappings = opts.query_mappings,
    selection_mappings = opts.selection_mappings,
  }

  telescope.find_files {
    prompt_title = prompt_title,
    cwd = opts.dir and tostring(opts.dir) or tostring(Obsidian.dir),
    find_command = self:_build_find_cmd(),
    attach_mappings = function(_, map)
      attach_picker_mappings(map, {
        entry_key = "path",
        callback = opts.callback,
        query_mappings = opts.query_mappings,
        selection_mappings = opts.selection_mappings,
      })
      return true
    end,
  }
end

---@param opts obsidian.PickerGrepOpts|? Options.
TelescopePicker.grep = function(self, opts)
  opts = opts or {}

  local cwd = opts.dir and Path:new(opts.dir) or Obsidian.dir

  local prompt_title = self:_build_prompt {
    prompt_title = opts.prompt_title,
    query_mappings = opts.query_mappings,
    selection_mappings = opts.selection_mappings,
  }

  local attach_mappings = function(_, map)
    attach_picker_mappings(map, {
      entry_key = "path",
      callback = opts.callback,
      query_mappings = opts.query_mappings,
      selection_mappings = opts.selection_mappings,
      initial_query = opts.query,
    })
    return true
  end

  if opts.query and string.len(opts.query) > 0 then
    telescope.grep_string {
      prompt_title = prompt_title,
      cwd = tostring(cwd),
      vimgrep_arguments = self:_build_grep_cmd(),
      search = opts.query,
      attach_mappings = attach_mappings,
    }
  else
    telescope.live_grep {
      prompt_title = prompt_title,
      cwd = tostring(cwd),
      vimgrep_arguments = self:_build_grep_cmd(),
      attach_mappings = attach_mappings,
    }
  end
end

---@param values string[]|obsidian.PickerEntry[]
---@param opts obsidian.PickerPickOpts|? Options.
TelescopePicker.pick = function(self, values, opts)
  local pickers = require "telescope.pickers"
  local finders = require "telescope.finders"
  local conf = require "telescope.config"
  local make_entry = require "telescope.make_entry"

  self.calling_bufnr = vim.api.nvim_get_current_buf()

  opts = opts and opts or {}

  local picker_opts = {
    attach_mappings = function(_, map)
      attach_picker_mappings(map, {
        entry_key = "value",
        callback = opts.callback,
        allow_multiple = opts.allow_multiple,
        query_mappings = opts.query_mappings,
        selection_mappings = opts.selection_mappings,
      })
      return true
    end,
  }

  local displayer = function(entry)
    return self:_make_display(entry.raw)
  end

  local prompt_title = self:_build_prompt {
    prompt_title = opts.prompt_title,
    query_mappings = opts.query_mappings,
    selection_mappings = opts.selection_mappings,
  }

  local previewer
  if type(values[1]) == "table" then
    previewer = conf.values.grep_previewer(picker_opts)
    -- Get theme to use.
    if conf.pickers then
      for _, picker_name in ipairs { "grep_string", "live_grep", "find_files" } do
        local picker_conf = conf.pickers[picker_name]
        if picker_conf and picker_conf.theme then
          picker_opts =
            vim.tbl_extend("force", picker_opts, require("telescope.themes")["get_" .. picker_conf.theme] {})
          break
        end
      end
    end
  end

  local make_entry_from_string = make_entry.gen_from_string(picker_opts)

  pickers
    .new(picker_opts, {
      prompt_title = prompt_title,
      finder = finders.new_table {
        results = values,
        entry_maker = function(v)
          if type(v) == "string" then
            return make_entry_from_string(v)
          else
            local ordinal = v.ordinal
            if ordinal == nil then
              ordinal = ""
              if type(v.display) == "string" then
                ordinal = ordinal .. v.display
              end
              if v.filename ~= nil then
                ordinal = ordinal .. " " .. v.filename
              end
            end

            return {
              value = v.value,
              display = displayer,
              ordinal = ordinal,
              filename = v.filename,
              valid = v.valid,
              lnum = v.lnum,
              col = v.col,
              raw = v,
            }
          end
        end,
      },
      sorter = conf.values.generic_sorter(picker_opts),
      previewer = previewer,
    })
    :find()
end

return TelescopePicker

local snacks_picker = require "snacks.picker"

local Path = require "obsidian.path"
local abc = require "obsidian.abc"
local Picker = require "obsidian.pickers.picker"

local function debug_once(msg, ...)
  --    vim.notify(msg .. vim.inspect(...))
end

---@param mapping table
---@return table
local function notes_mappings(mapping)
  if type(mapping) == "table" then
    local opts = { win = { input = { keys = {} } }, actions = {} }
    for k, v in pairs(mapping) do
      local name = string.gsub(v.desc, " ", "_")
      opts.win.input.keys = {
        [k] = { name, mode = { "n", "i" }, desc = v.desc },
      }
      opts.actions[name] = function(picker, item)
        debug_once("mappings :", item)
        picker:close()
        vim.schedule(function()
          v.callback(item.value or item._path)
        end)
      end
    end
    return opts
  end
  return {}
end

---@class obsidian.pickers.SnacksPicker : obsidian.Picker
local SnacksPicker = abc.new_class({
  ---@diagnostic disable-next-line: unused-local
  __tostring = function(self)
    return "SnacksPicker()"
  end,
}, Picker)

---@param opts obsidian.PickerFindOpts|? Options.
SnacksPicker.find_files = function(self, opts)
  opts = opts or {}

  ---@type obsidian.Path
  local dir = opts.dir.filename and Path:new(opts.dir.filename) or Obsidian.dir

  local map = vim.tbl_deep_extend("force", {}, notes_mappings(opts.selection_mappings))

  local pick_opts = vim.tbl_extend("force", map or {}, {
    source = "files",
    title = opts.prompt_title,
    cwd = tostring(dir),
    confirm = function(picker, item, action)
      picker:close()
      if item then
        if opts.callback then
          debug_once("find files callback: ", item)
          opts.callback(item._path)
        else
          debug_once("find files jump: ", item)
          snacks_picker.actions.jump(picker, item, action)
        end
      end
    end,
  })
  snacks_picker.pick(pick_opts)
end

---@param opts obsidian.PickerGrepOpts|? Options.
SnacksPicker.grep = function(self, opts)
  opts = opts or {}

  debug_once("grep opts : ", opts)

  ---@type obsidian.Path
  local dir = opts.dir.filename and Path:new(opts.dir.filename) or Obsidian.dir

  local map = vim.tbl_deep_extend("force", {}, notes_mappings(opts.selection_mappings))

  local pick_opts = vim.tbl_extend("force", map or {}, {
    source = "grep",
    title = opts.prompt_title,
    cwd = tostring(dir),
    confirm = function(picker, item, action)
      picker:close()
      if item then
        if opts.callback then
          debug_once("grep callback: ", item)
          opts.callback(item._path or item.filename)
        else
          debug_once("grep jump: ", item)
          snacks_picker.actions.jump(picker, item, action)
        end
      end
    end,
  })
  snacks_picker.pick(pick_opts)
end

---@param values string[]|obsidian.PickerEntry[]
---@param opts obsidian.PickerPickOpts|? Options.
---@diagnostic disable-next-line: unused-local
SnacksPicker.pick = function(self, values, opts)
  self.calling_bufnr = vim.api.nvim_get_current_buf()

  opts = opts or {}

  debug_once("pick opts: ", opts)

  local entries = {}
  for _, value in ipairs(values) do
    if type(value) == "string" then
      table.insert(entries, {
        text = value,
        value = value,
      })
    elseif value.valid ~= false then
      local name = self:_make_display(value)
      table.insert(entries, {
        text = name,
        buf = self.calling_bufnr,
        filename = value.filename,
        value = value.value,
        pos = { value.lnum, value.col or 0 },
      })
    end
  end

  local map = vim.tbl_deep_extend("force", {}, notes_mappings(opts.selection_mappings))

  local pick_opts = vim.tbl_extend("force", map or {}, {
    tilte = opts.prompt_title,
    items = entries,
    layout = {
      preview = false,
    },
    format = "text",
    confirm = function(picker, item, action)
      picker:close()
      if item then
        if opts.callback then
          debug_once("pick callback: ", item)
          opts.callback(item.value)
        else
          debug_once("pick jump: ", item)
          snacks_picker.actions.jump(picker, item, action)
        end
      end
    end,
  })

  snacks_picker.pick(pick_opts)
end

return SnacksPicker

local abc = require "obsidian.abc"
local log = require "obsidian.log"
local api = require "obsidian.api"
local strings = require "plenary.strings"
local Note = require "obsidian.note"
local Path = require "obsidian.path"

---@class obsidian.Picker : obsidian.ABC
---
---@field calling_bufnr integer
local Picker = abc.new_class()

Picker.new = function()
  local self = Picker.init()
  self.calling_bufnr = vim.api.nvim_get_current_buf()
  return self
end

-------------------------------------------------------------------
--- Abstract methods that need to be implemented by subclasses. ---
-------------------------------------------------------------------

---@class obsidian.PickerMappingOpts
---
---@field desc string
---@field callback fun(...)
---@field fallback_to_query boolean|?
---@field keep_open boolean|?
---@field allow_multiple boolean|?

---@alias obsidian.PickerMappingTable table<string, obsidian.PickerMappingOpts>

---@class obsidian.PickerFindOpts
---
---@field prompt_title string|?
---@field dir string|obsidian.Path|?
---@field callback fun(path: string)|?
---@field no_default_mappings boolean|?
---@field query_mappings obsidian.PickerMappingTable|?
---@field selection_mappings obsidian.PickerMappingTable|?

--- Find files in a directory.
---
---@param opts obsidian.PickerFindOpts|? Options.
---
--- Options:
---  `prompt_title`: Title for the prompt window.
---  `dir`: Directory to search in.
---  `callback`: Callback to run with the selected entry.
---  `no_default_mappings`: Don't apply picker's default mappings.
---  `query_mappings`: Mappings that run with the query prompt.
---  `selection_mappings`: Mappings that run with the current selection.
---
---@diagnostic disable-next-line: unused-local
Picker.find_files = function(self, opts)
  error "not implemented"
end

---@class obsidian.PickerGrepOpts
---
---@field prompt_title string|?
---@field dir string|obsidian.Path|?
---@field query string|?
---@field callback fun(path: string)|?
---@field no_default_mappings boolean|?
---@field query_mappings obsidian.PickerMappingTable
---@field selection_mappings obsidian.PickerMappingTable

--- Grep for a string.
---
---@param opts obsidian.PickerGrepOpts|? Options.
---
--- Options:
---  `prompt_title`: Title for the prompt window.
---  `dir`: Directory to search in.
---  `query`: Initial query to grep for.
---  `callback`: Callback to run with the selected path.
---  `no_default_mappings`: Don't apply picker's default mappings.
---  `query_mappings`: Mappings that run with the query prompt.
---  `selection_mappings`: Mappings that run with the current selection.
---
---@diagnostic disable-next-line: unused-local
Picker.grep = function(self, opts)
  error "not implemented"
end

---@class obsidian.PickerEntry
---
---@field value any
---@field ordinal string|?
---@field display string|?
---@field filename string|?
---@field valid boolean|?
---@field lnum integer|?
---@field col integer|?
---@field icon string|?
---@field icon_hl string|?

---@class obsidian.PickerPickOpts
---
---@field prompt_title string|?
---@field callback fun(value: any, ...: any)|?
---@field allow_multiple boolean|?
---@field query_mappings obsidian.PickerMappingTable|?
---@field selection_mappings obsidian.PickerMappingTable|?

--- Pick from a list of items.
---
---@param values string[]|obsidian.PickerEntry[] Items to pick from.
---@param opts obsidian.PickerPickOpts|? Options.
---
--- Options:
---  `prompt_title`: Title for the prompt window.
---  `callback`: Callback to run with the selected item(s).
---  `allow_multiple`: Allow multiple selections to pass to the callback.
---  `query_mappings`: Mappings that run with the query prompt.
---  `selection_mappings`: Mappings that run with the current selection.
---
---@diagnostic disable-next-line: unused-local
Picker.pick = function(self, values, opts)
  error "not implemented"
end

------------------------------------------------------------------
--- Concrete methods with a default implementation subclasses. ---
------------------------------------------------------------------

--- Find notes by filename.
---
---@param opts { prompt_title: string|?, callback: fun(path: string)|?, no_default_mappings: boolean|? }|? Options.
---
--- Options:
---  `prompt_title`: Title for the prompt window.
---  `callback`: Callback to run with the selected note path.
---  `no_default_mappings`: Don't apply picker's default mappings.
Picker.find_notes = function(self, opts)
  self.calling_bufnr = vim.api.nvim_get_current_buf()

  opts = opts or {}

  local query_mappings
  local selection_mappings
  if not opts.no_default_mappings then
    query_mappings = self:_note_query_mappings()
    selection_mappings = self:_note_selection_mappings()
  end

  return self:find_files {
    prompt_title = opts.prompt_title or "Notes",
    dir = Obsidian.dir,
    callback = opts.callback,
    no_default_mappings = opts.no_default_mappings,
    query_mappings = query_mappings,
    selection_mappings = selection_mappings,
  }
end

--- Find templates by filename.
---
---@param opts { prompt_title: string|?, callback: fun(path: string) }|? Options.
---
--- Options:
---  `callback`: Callback to run with the selected template path.
Picker.find_templates = function(self, opts)
  self.calling_bufnr = vim.api.nvim_get_current_buf()

  opts = opts or {}

  local templates_dir = api.templates_dir()

  if templates_dir == nil then
    log.err "Templates folder is not defined or does not exist"
    return
  end

  return self:find_files {
    prompt_title = opts.prompt_title or "Templates",
    callback = opts.callback,
    dir = templates_dir,
    no_default_mappings = true,
  }
end

--- Grep search in notes.
---
---@param opts { prompt_title: string|?, query: string|?, callback: fun(path: string)|?, no_default_mappings: boolean|? }|? Options.
---
--- Options:
---  `prompt_title`: Title for the prompt window.
---  `query`: Initial query to grep for.
---  `callback`: Callback to run with the selected path.
---  `no_default_mappings`: Don't apply picker's default mappings.
Picker.grep_notes = function(self, opts)
  self.calling_bufnr = vim.api.nvim_get_current_buf()

  opts = opts or {}

  local query_mappings
  local selection_mappings
  if not opts.no_default_mappings then
    query_mappings = self:_note_query_mappings()
    selection_mappings = self:_note_selection_mappings()
  end

  self:grep {
    prompt_title = opts.prompt_title or "Grep notes",
    dir = Obsidian.dir,
    query = opts.query,
    callback = opts.callback,
    no_default_mappings = opts.no_default_mappings,
    query_mappings = query_mappings,
    selection_mappings = selection_mappings,
  }
end

--- Open picker with a list of notes.
---
---@param notes obsidian.Note[]
---@param opts { prompt_title: string|?, callback: fun(note: obsidian.Note, ...: obsidian.Note), allow_multiple: boolean|?, no_default_mappings: boolean|? }|? Options.
---
--- Options:
---  `prompt_title`: Title for the prompt window.
---  `callback`: Callback to run with the selected note(s).
---  `allow_multiple`: Allow multiple selections to pass to the callback.
---  `no_default_mappings`: Don't apply picker's default mappings.
Picker.pick_note = function(self, notes, opts)
  self.calling_bufnr = vim.api.nvim_get_current_buf()

  opts = opts or {}

  local query_mappings
  local selection_mappings
  if not opts.no_default_mappings then
    query_mappings = self:_note_query_mappings()
    selection_mappings = self:_note_selection_mappings()
  end

  -- Launch picker with results.
  ---@type obsidian.PickerEntry[]
  local entries = {}
  for _, note in ipairs(notes) do
    assert(note.path)
    local rel_path = assert(note.path:vault_relative_path { strict = true })
    local display_name = note:display_name()
    entries[#entries + 1] = {
      value = note,
      display = display_name,
      ordinal = rel_path .. " " .. display_name,
      filename = tostring(note.path),
    }
  end

  self:pick(entries, {
    prompt_title = opts.prompt_title or "Notes",
    callback = opts.callback,
    allow_multiple = opts.allow_multiple,
    no_default_mappings = opts.no_default_mappings,
    query_mappings = query_mappings,
    selection_mappings = selection_mappings,
  })
end

--- Open picker with a list of tags.
---
---@param tags string[]
---@param opts { prompt_title: string|?, callback: fun(tag: string, ...: string), allow_multiple: boolean|?, no_default_mappings: boolean|? }|? Options.
---
--- Options:
---  `prompt_title`: Title for the prompt window.
---  `callback`: Callback to run with the selected tag(s).
---  `allow_multiple`: Allow multiple selections to pass to the callback.
---  `no_default_mappings`: Don't apply picker's default mappings.
Picker.pick_tag = function(self, tags, opts)
  self.calling_bufnr = vim.api.nvim_get_current_buf()

  opts = opts or {}

  local selection_mappings
  if not opts.no_default_mappings then
    selection_mappings = self:_tag_selection_mappings()
  end

  self:pick(tags, {
    prompt_title = opts.prompt_title or "Tags",
    callback = opts.callback,
    allow_multiple = opts.allow_multiple,
    no_default_mappings = opts.no_default_mappings,
    selection_mappings = selection_mappings,
  })
end

--------------------------------
--- Concrete helper methods. ---
--------------------------------

---@param key string|?
---@return boolean
local function key_is_set(key)
  if key ~= nil and string.len(key) > 0 then
    return true
  else
    return false
  end
end

--- Get query mappings to use for `find_notes()` or `grep_notes()`.
---@return obsidian.PickerMappingTable
Picker._note_query_mappings = function(self)
  ---@type obsidian.PickerMappingTable
  local mappings = {}

  if Obsidian.opts.picker.note_mappings and key_is_set(Obsidian.opts.picker.note_mappings.new) then
    mappings[Obsidian.opts.picker.note_mappings.new] = {
      desc = "new",
      callback = function(query)
        ---@diagnostic disable-next-line: missing-fields
        require "obsidian.commands.new"(require("obsidian").get_client(), { args = query })
      end,
    }
  end

  return mappings
end

--- Get selection mappings to use for `find_notes()` or `grep_notes()`.
---@return obsidian.PickerMappingTable
Picker._note_selection_mappings = function(self)
  ---@type obsidian.PickerMappingTable
  local mappings = {}

  if Obsidian.opts.picker.note_mappings and key_is_set(Obsidian.opts.picker.note_mappings.insert_link) then
    mappings[Obsidian.opts.picker.note_mappings.insert_link] = {
      desc = "insert link",
      callback = function(note_or_path)
        ---@type obsidian.Note
        local note
        if Note.is_note_obj(note_or_path) then
          note = note_or_path
        else
          note = Note.from_file(note_or_path)
        end
        local link = api.format_link(note, {})
        vim.api.nvim_put({ link }, "", false, true)
        require("obsidian.ui").update(0)
      end,
    }
  end

  return mappings
end

--- Get selection mappings to use for `pick_tag()`.
---@return obsidian.PickerMappingTable
Picker._tag_selection_mappings = function(self)
  ---@type obsidian.PickerMappingTable
  local mappings = {}

  if Obsidian.opts.picker.tag_mappings then
    if key_is_set(Obsidian.opts.picker.tag_mappings.tag_note) then
      mappings[Obsidian.opts.picker.tag_mappings.tag_note] = {
        desc = "tag note",
        callback = function(...)
          local tags = { ... }

          local note = api.current_note(self.calling_bufnr)
          if not note then
            log.warn("'%s' is not a note in your workspace", vim.api.nvim_buf_get_name(self.calling_bufnr))
            return
          end

          -- Add the tag and save the new frontmatter to the buffer.
          local tags_added = {}
          local tags_not_added = {}
          for _, tag in ipairs(tags) do
            if note:add_tag(tag) then
              table.insert(tags_added, tag)
            else
              table.insert(tags_not_added, tag)
            end
          end

          if #tags_added > 0 then
            if note:update_frontmatter(self.calling_bufnr) then
              log.info("Added tags %s to frontmatter", tags_added)
            else
              log.warn "Frontmatter unchanged"
            end
          end

          if #tags_not_added > 0 then
            log.warn("Note already has tags %s", tags_not_added)
          end
        end,
        fallback_to_query = true,
        keep_open = true,
        allow_multiple = true,
      }
    end

    if key_is_set(Obsidian.opts.picker.tag_mappings.insert_tag) then
      mappings[Obsidian.opts.picker.tag_mappings.insert_tag] = {
        desc = "insert tag",
        callback = function(tag)
          vim.api.nvim_put({ "#" .. tag }, "", false, true)
        end,
        fallback_to_query = true,
      }
    end
  end

  return mappings
end

---@param opts { prompt_title: string, query_mappings: obsidian.PickerMappingTable|?, selection_mappings: obsidian.PickerMappingTable|? }|?
---@return string
---@diagnostic disable-next-line: unused-local
Picker._build_prompt = function(self, opts)
  opts = opts or {}

  ---@type string
  local prompt = opts.prompt_title or "Find"
  if string.len(prompt) > 50 then
    prompt = string.sub(prompt, 1, 50) .. "…"
  end

  prompt = prompt .. " | <CR> confirm"

  if opts.query_mappings then
    local keys = vim.tbl_keys(opts.query_mappings)
    table.sort(keys)
    for _, key in ipairs(keys) do
      local mapping = opts.query_mappings[key]
      prompt = prompt .. " | " .. key .. " " .. mapping.desc
    end
  end

  if opts.selection_mappings then
    local keys = vim.tbl_keys(opts.selection_mappings)
    table.sort(keys)
    for _, key in ipairs(keys) do
      local mapping = opts.selection_mappings[key]
      prompt = prompt .. " | " .. key .. " " .. mapping.desc
    end
  end

  return prompt
end

---@param entry obsidian.PickerEntry
---
---@return string, { [1]: { [1]: integer, [2]: integer }, [2]: string }[]
---@diagnostic disable-next-line: unused-local
Picker._make_display = function(self, entry)
  ---@type string
  local display = ""
  ---@type { [1]: { [1]: integer, [2]: integer }, [2]: string }[]
  local highlights = {}

  if entry.filename ~= nil then
    local icon, icon_hl
    if entry.icon then
      icon = entry.icon
      icon_hl = entry.icon_hl
    else
      icon, icon_hl = api.get_icon(entry.filename)
    end

    if icon ~= nil then
      display = display .. icon .. " "
      if icon_hl ~= nil then
        highlights[#highlights + 1] = { { 0, strings.strdisplaywidth(icon) }, icon_hl }
      end
    end

    display = display .. Path.new(entry.filename):vault_relative_path { strict = true }

    if entry.lnum ~= nil then
      display = display .. ":" .. entry.lnum

      if entry.col ~= nil then
        display = display .. ":" .. entry.col
      end
    end

    if entry.display ~= nil then
      display = display .. ":" .. entry.display
    end
  elseif entry.display ~= nil then
    if entry.icon ~= nil then
      display = entry.icon .. " "
    end
    display = display .. entry.display
  else
    if entry.icon ~= nil then
      display = entry.icon .. " "
    end
    display = display .. tostring(entry.value)
  end

  return assert(display), highlights
end

---@return string[]
Picker._build_find_cmd = function(self)
  local search = require "obsidian.search"
  local search_opts = { sort_by = Obsidian.opts.sort_by, sort_reversed = Obsidian.opts.sort_reversed }
  return search.build_find_cmd(".", nil, search_opts)
end

Picker._build_grep_cmd = function(self)
  local search = require "obsidian.search"
  local search_opts = {
    sort_by = Obsidian.opts.sort_by,
    sort_reversed = Obsidian.opts.sort_reversed,
    smart_case = true,
    fixed_strings = true,
  }
  return search.build_grep_cmd(search_opts)
end

return Picker

local M = {}
local log = require "obsidian.log"
local util = require "obsidian.util"
local iter, string, table = vim.iter, string, table
local Path = require "obsidian.path"
local search = require "obsidian.search"

---@param dir string | obsidian.Path
---@return Iter
M.dir = function(dir)
  dir = tostring(dir)
  local dir_opts = {
    depth = 10,
    skip = function(p)
      return not vim.startswith(p, ".") and p ~= vim.fs.basename(tostring(M.templates_dir()))
    end,
    follow = true,
  }

  return vim
    .iter(vim.fs.dir(dir, dir_opts))
    :filter(function(path)
      return vim.endswith(path, ".md")
    end)
    :map(function(path)
      return vim.fs.joinpath(dir, path)
    end)
end

--- Get the templates folder.
---
---@return obsidian.Path|?
M.templates_dir = function(workspace)
  local opts = Obsidian.opts

  local Workspace = require "obsidian.workspace"

  if workspace and workspace ~= Obsidian.workspace then
    opts = Workspace.normalize_opts(workspace)
  end

  if opts.templates == nil or opts.templates.folder == nil then
    return nil
  end

  local paths_to_check = { Obsidian.workspace.root / opts.templates.folder, Path.new(opts.templates.folder) }
  for _, path in ipairs(paths_to_check) do
    if path:is_dir() then
      return path
    end
  end

  log.err_once("'%s' is not a valid templates directory", opts.templates.folder)
  return nil
end

--- Check if a path represents a note in the workspace.
---
---@param path string|obsidian.Path
---@param workspace obsidian.Workspace|?
---
---@return boolean
M.path_is_note = function(path, workspace)
  path = Path.new(path):resolve()
  workspace = workspace or Obsidian.workspace

  local in_vault = path.filename:find(vim.pesc(tostring(workspace.root))) ~= nil
  if not in_vault then
    return false
  end

  -- Notes have to be markdown file.
  if path.suffix ~= ".md" then
    return false
  end

  -- Ignore markdown files in the templates directory.
  local templates_dir = M.templates_dir(workspace)
  if templates_dir ~= nil then
    if templates_dir:is_parent_of(path) then
      return false
    end
  end

  return true
end

--- Get the current note from a buffer.
---
---@param bufnr integer|?
---@param opts obsidian.note.LoadOpts|?
---
---@return obsidian.Note|?
---@diagnostic disable-next-line: unused-local
M.current_note = function(bufnr, opts)
  bufnr = bufnr or 0
  local Note = require "obsidian.note"
  if not M.path_is_note(vim.api.nvim_buf_get_name(bufnr)) then
    return nil
  end

  opts = opts or {}
  if not opts.max_lines then
    opts.max_lines = Obsidian.opts.search_max_lines
  end
  return Note.from_buffer(bufnr, opts)
end

---builtin functions that are impure, interacts with editor state, like vim.api

---Toggle the checkbox on the current line.
---
---@param states table|nil Optional table containing checkbox states (e.g., {" ", "x"}).
---@param line_num number|nil Optional line number to toggle the checkbox on. Defaults to the current line.
M.toggle_checkbox = function(states, line_num)
  if not util.in_node { "list", "paragraph" } or util.in_node "block_quote" then
    return
  end
  line_num = line_num or unpack(vim.api.nvim_win_get_cursor(0))
  local line = vim.api.nvim_buf_get_lines(0, line_num - 1, line_num, false)[1]

  local checkboxes = states or { " ", "x" }

  if util.is_checkbox(line) then
    for i, check_char in ipairs(checkboxes) do
      if string.match(line, "^.* %[" .. vim.pesc(check_char) .. "%].*") then
        i = i % #checkboxes
        line = string.gsub(line, vim.pesc("[" .. check_char .. "]"), "[" .. checkboxes[i + 1] .. "]", 1)
        break
      end
    end
  elseif Obsidian.opts.checkbox.create_new then
    local unordered_list_pattern = "^(%s*)[-*+] (.*)"
    if string.match(line, unordered_list_pattern) then
      line = string.gsub(line, unordered_list_pattern, "%1- [ ] %2")
    else
      line = string.gsub(line, "^(%s*)", "%1- [ ] ")
    end
  else
    goto out
  end

  vim.api.nvim_buf_set_lines(0, line_num - 1, line_num, true, { line })
  ::out::
end

---@return [number, number, number, number] tuple containing { buf, win, row, col }
M.get_active_window_cursor_location = function()
  local buf = vim.api.nvim_win_get_buf(0)
  local win = vim.api.nvim_get_current_win()
  local row, col = unpack(vim.api.nvim_win_get_cursor(win))
  local location = { buf, win, row, col }
  return location
end

--- Create a formatted markdown / wiki link for a note.
---
---@param note obsidian.Note|obsidian.Path|string The note/path to link to.
---@param opts { label: string|?, link_style: obsidian.config.LinkStyle|?, id: string|integer|?, anchor: obsidian.note.HeaderAnchor|?, block: obsidian.note.Block|? }|? Options.
---
---@return string
M.format_link = function(note, opts)
  local config = require "obsidian.config"
  opts = opts or {}

  ---@type string, string, string|integer|?
  local rel_path, label, note_id
  if type(note) == "string" or Path.is_path_obj(note) then
    ---@cast note string|obsidian.Path
    -- rel_path = tostring(self:vault_relative_path(note, { strict = true }))
    rel_path = assert(Path.new(note):vault_relative_path { strict = true })
    label = opts.label or tostring(note)
    note_id = opts.id
  else
    ---@cast note obsidian.Note
    -- rel_path = tostring(self:vault_relative_path(note.path, { strict = true }))
    rel_path = assert(note.path:vault_relative_path { strict = true })
    label = opts.label or note:display_name()
    note_id = opts.id or note.id
  end

  local link_style = opts.link_style
  if link_style == nil then
    link_style = Obsidian.opts.preferred_link_style
  end

  local new_opts = { path = rel_path, label = label, id = note_id, anchor = opts.anchor, block = opts.block }

  if link_style == config.LinkStyle.markdown then
    return Obsidian.opts.markdown_link_func(new_opts)
  elseif link_style == config.LinkStyle.wiki or link_style == nil then
    return Obsidian.opts.wiki_link_func(new_opts)
  else
    error(string.format("Invalid link style '%s'", link_style))
  end
end

---Return the full link under cursror
---
---@return string? link
---@return obsidian.search.RefTypes? link_type
M.cursor_link = function()
  local line = vim.api.nvim_get_current_line()
  local _, cur_col = unpack(vim.api.nvim_win_get_cursor(0))
  cur_col = cur_col + 1 -- 0-indexed column to 1-indexed lua string position

  local refs = search.find_refs(line, { include_naked_urls = true, include_file_urls = true, include_block_ids = true })

  local match = iter(refs):find(function(match)
    local open, close = unpack(match)
    return cur_col >= open and cur_col <= close
  end)
  if match then
    return line:sub(match[1], match[2]), match[3]
  end
end

---Get the tag under the cursor, if there is one.
---@return string?
M.cursor_tag = function()
  local current_line = vim.api.nvim_get_current_line()
  local _, cur_col = unpack(vim.api.nvim_win_get_cursor(0))
  cur_col = cur_col + 1 -- nvim_win_get_cursor returns 0-indexed column

  for match in iter(search.find_tags(current_line)) do
    local open, close, _ = unpack(match)
    if open <= cur_col and cur_col <= close then
      return string.sub(current_line, open + 1, close)
    end
  end

  return nil
end

--- Get the heading under the cursor, if there is one.
---@return { header: string, level: integer, anchor: string }|?
M.cursor_heading = function()
  return util.parse_header(vim.api.nvim_get_current_line())
end

------------------
--- buffer api ---
------------------

--- Check if a buffer is empty.
---
---@param bufnr integer|?
---
---@return boolean
M.buffer_is_empty = function(bufnr)
  bufnr = bufnr or 0
  if vim.api.nvim_buf_line_count(bufnr) > 1 then
    return false
  else
    local first_text = vim.api.nvim_buf_get_text(bufnr, 0, 0, 0, 0, {})
    if vim.tbl_isempty(first_text) or first_text[1] == "" then
      return true
    else
      return false
    end
  end
end

--- Open a buffer for the corresponding path.
---
---@param path string|obsidian.Path
---@param opts { line: integer|?, col: integer|?, cmd: string|? }|?
---@return integer bufnr
M.open_buffer = function(path, opts)
  path = Path.new(path):resolve()
  opts = opts and opts or {}
  local cmd = vim.trim(opts.cmd and opts.cmd or "e")

  ---@type integer|?
  local result_bufnr

  -- Check for buffer in windows and use 'drop' command if one is found.
  for _, winnr in ipairs(vim.api.nvim_list_wins()) do
    local bufnr = vim.api.nvim_win_get_buf(winnr)
    local bufname = vim.api.nvim_buf_get_name(bufnr)
    if bufname == tostring(path) then
      cmd = "drop"
      result_bufnr = bufnr
      break
    end
  end

  vim.cmd(string.format("%s %s", cmd, vim.fn.fnameescape(tostring(path))))
  if opts.line then
    vim.api.nvim_win_set_cursor(0, { tonumber(opts.line), opts.col and opts.col or 0 })
  end

  if not result_bufnr then
    result_bufnr = vim.api.nvim_get_current_buf()
  end

  return result_bufnr
end

---Get an iterator of (bufnr, bufname) over all named buffers. The buffer names will be absolute paths.
---
---@return function () -> (integer, string)|?
M.get_named_buffers = function()
  local idx = 0
  local buffers = vim.api.nvim_list_bufs()

  ---@return integer|?
  ---@return string|?
  return function()
    while idx < #buffers do
      idx = idx + 1
      local bufnr = buffers[idx]
      if vim.api.nvim_buf_is_loaded(bufnr) then
        return bufnr, vim.api.nvim_buf_get_name(bufnr)
      end
    end
  end
end

----------------
--- text api ---
----------------

--- Get the current visual selection of text and exit visual mode.
---
---@param opts { strict: boolean|? }|?
---
---@return { lines: string[], selection: string, csrow: integer, cscol: integer, cerow: integer, cecol: integer }|?
M.get_visual_selection = function(opts)
  opts = opts or {}
  -- Adapted from fzf-lua:
  -- https://github.com/ibhagwan/fzf-lua/blob/6ee73fdf2a79bbd74ec56d980262e29993b46f2b/lua/fzf-lua/utils.lua#L434-L466
  -- this will exit visual mode
  -- use 'gv' to reselect the text
  local _, csrow, cscol, cerow, cecol
  local mode = vim.fn.mode()
  if opts.strict and not vim.endswith(string.lower(mode), "v") then
    return
  end

  if mode == "v" or mode == "V" or mode == "" then
    -- if we are in visual mode use the live position
    _, csrow, cscol, _ = unpack(vim.fn.getpos ".")
    _, cerow, cecol, _ = unpack(vim.fn.getpos "v")
    if mode == "V" then
      -- visual line doesn't provide columns
      cscol, cecol = 0, 999
    end
    -- exit visual mode
    vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", true, false, true), "n", true)
  else
    -- otherwise, use the last known visual position
    _, csrow, cscol, _ = unpack(vim.fn.getpos "'<")
    _, cerow, cecol, _ = unpack(vim.fn.getpos "'>")
  end

  -- Swap vars if needed
  if cerow < csrow then
    csrow, cerow = cerow, csrow
    cscol, cecol = cecol, cscol
  elseif cerow == csrow and cecol < cscol then
    cscol, cecol = cecol, cscol
  end

  local lines = vim.fn.getline(csrow, cerow)
  assert(type(lines) == "table")
  if vim.tbl_isempty(lines) then
    return
  end

  -- When the whole line is selected via visual line mode ("V"), cscol / cecol will be equal to "v:maxcol"
  -- for some odd reason. So change that to what they should be here. See ':h getpos' for more info.
  local maxcol = vim.api.nvim_get_vvar "maxcol"
  if cscol == maxcol then
    cscol = string.len(lines[1])
  end
  if cecol == maxcol then
    cecol = string.len(lines[#lines])
  end

  ---@type string
  local selection
  local n = #lines
  if n <= 0 then
    selection = ""
  elseif n == 1 then
    selection = string.sub(lines[1], cscol, cecol)
  elseif n == 2 then
    selection = string.sub(lines[1], cscol) .. "\n" .. string.sub(lines[n], 1, cecol)
  else
    selection = string.sub(lines[1], cscol)
      .. "\n"
      .. table.concat(lines, "\n", 2, n - 1)
      .. "\n"
      .. string.sub(lines[n], 1, cecol)
  end

  return {
    lines = lines,
    selection = selection,
    csrow = csrow,
    cscol = cscol,
    cerow = cerow,
    cecol = cecol,
  }
end

------------------
--- UI helpers ---
------------------

---Get the strategy for opening notes
---
---@param opt obsidian.config.OpenStrategy
---@return string
M.get_open_strategy = function(opt)
  local OpenStrategy = require("obsidian.config").OpenStrategy

  -- either 'leaf', 'row' for vertically split windows, or 'col' for horizontally split windows
  local cur_layout = vim.fn.winlayout()[1]

  if vim.startswith(OpenStrategy.hsplit, opt) then
    if cur_layout ~= "col" then
      return "split "
    else
      return "e "
    end
  elseif vim.startswith(OpenStrategy.vsplit, opt) then
    if cur_layout ~= "row" then
      return "vsplit "
    else
      return "e "
    end
  elseif vim.startswith(OpenStrategy.vsplit_force, opt) then
    return "vsplit "
  elseif vim.startswith(OpenStrategy.hsplit_force, opt) then
    return "hsplit "
  elseif vim.startswith(OpenStrategy.current, opt) then
    return "e "
  else
    log.err("undefined open strategy '%s'", opt)
    return "e "
  end
end

----------------------------
--- Integration helpers ----
----------------------------

--- Get the path to where a plugin is installed.
---
---@param name string
---@return string|?
local get_src_root = function(name)
  return vim.iter(vim.api.nvim_list_runtime_paths()):find(function(path)
    return vim.endswith(path, name)
  end)
end

--- Get info about a plugin.
---
---@param name string
---
---@return { commit: string|?, path: string }|?
M.get_plugin_info = function(name)
  local src_root = get_src_root(name)
  if not src_root then
    return
  end
  local out = { path = src_root }
  local obj = vim.system({ "git", "rev-parse", "HEAD" }, { cwd = src_root }):wait(1000)
  if obj.code == 0 then
    out.commit = vim.trim(obj.stdout)
  else
    out.commit = "unknown"
  end
  return out
end

--- Get info about a external dependency.
---
---@param cmd string
---@return string|?
M.get_external_dependency_info = function(cmd)
  local obj = vim.system({ cmd, "--version" }, {}):wait(1000)
  if obj.code ~= 0 then
    return
  end
  local version = vim.version.parse(obj.stdout)
  if version then
    return ("%d.%d.%d"):format(version.major, version.minor, version.patch)
  end
end

------------------
--- UI helpers ---
------------------

local INPUT_CANCELLED = "~~~INPUT-CANCELLED~~~"

--- Prompt user for an input. Returns nil if canceled, otherwise a string (possibly empty).
---
---@param prompt string
---@param opts { completion: string|?, default: string|? }|?
---
---@return string|?
M.input = function(prompt, opts)
  opts = opts or {}

  if not vim.endswith(prompt, " ") then
    prompt = prompt .. " "
  end

  local input = vim.trim(
    vim.fn.input { prompt = prompt, completion = opts.completion, default = opts.default, cancelreturn = INPUT_CANCELLED }
  )

  if input ~= INPUT_CANCELLED then
    return input
  else
    return nil
  end
end

--- Prompt user for a confirmation.
---
---@param prompt string
---
---@return boolean
M.confirm = function(prompt)
  if not vim.endswith(util.rstrip_whitespace(prompt), "[Y/n]") then
    prompt = util.rstrip_whitespace(prompt) .. " [Y/n] "
  end

  local confirmation = M.input(prompt)
  if confirmation == nil then
    return false
  end

  confirmation = string.lower(confirmation)

  if confirmation == "" or confirmation == "y" or confirmation == "yes" then
    return true
  else
    return false
  end
end

---@enum OSType
M.OSType = {
  Linux = "Linux",
  Wsl = "Wsl",
  Windows = "Windows",
  Darwin = "Darwin",
  FreeBSD = "FreeBSD",
}

M._current_os = nil

---Get the running operating system.
---Reference https://vi.stackexchange.com/a/2577/33116
---@return OSType
M.get_os = function()
  if M._current_os ~= nil then
    return M._current_os
  end

  local this_os
  if vim.fn.has "win32" == 1 then
    this_os = M.OSType.Windows
  else
    local sysname = vim.uv.os_uname().sysname
    local release = vim.uv.os_uname().release:lower()
    if sysname:lower() == "linux" and string.find(release, "microsoft") then
      this_os = M.OSType.Wsl
    else
      this_os = sysname
    end
  end

  assert(this_os)
  M._current_os = this_os
  return this_os
end

--- Get a nice icon for a file or URL, if possible.
---
---@param path string
---
---@return string|?, string|? (icon, hl_group) The icon and highlight group.
M.get_icon = function(path)
  if util.is_url(path) then
    local icon = ""
    local _, hl_group = M.get_icon "blah.html"
    return icon, hl_group
  else
    local ok, res = pcall(function()
      local icon, hl_group = require("nvim-web-devicons").get_icon(path, nil, { default = true })
      return { icon, hl_group }
    end)
    if ok and type(res) == "table" then
      local icon, hlgroup = unpack(res)
      return icon, hlgroup
    elseif vim.endswith(path, ".md") then
      return ""
    end
  end
  return nil
end

--- Resolve a basename to full path inside the vault.
---
---@param src string
---@return string
M.resolve_image_path = function(src)
  local img_folder = Obsidian.opts.attachments.img_folder

  ---@cast img_folder -nil
  if vim.startswith(img_folder, ".") then
    local dirname = Path.new(vim.fs.dirname(vim.api.nvim_buf_get_name(0)))
    return tostring(dirname / img_folder / src)
  else
    return tostring(Obsidian.dir / img_folder / src)
  end
end

--- Follow a link. If the link argument is `nil` we attempt to follow a link under the cursor.
---
---@param link string
---@param opts { open_strategy: obsidian.config.OpenStrategy|? }|?
M.follow_link = function(link, opts)
  opts = opts and opts or {}
  local Note = require "obsidian.note"

  search.resolve_link_async(link, function(results)
    if #results == 0 then
      return
    end

    ---@param res obsidian.ResolveLinkResult
    local function follow_link(res)
      if res.url ~= nil then
        Obsidian.opts.follow_url_func(res.url)
        return
      end

      if util.is_img(res.location) then
        local path = Obsidian.dir / res.location
        Obsidian.opts.follow_img_func(tostring(path))
        return
      end

      if res.note ~= nil then
        -- Go to resolved note.
        return res.note:open { line = res.line, col = res.col, open_strategy = opts.open_strategy }
      end

      if res.link_type == search.RefTypes.Wiki or res.link_type == search.RefTypes.WikiWithAlias then
        -- Prompt to create a new note.
        if M.confirm("Create new note '" .. res.location .. "'?") then
          -- Create a new note.
          ---@type string|?, string[]
          local id, aliases
          if res.name == res.location then
            aliases = {}
          else
            aliases = { res.name }
            id = res.location
          end

          local note = Note.create { title = res.name, id = id, aliases = aliases }
          return note:open {
            open_strategy = opts.open_strategy,
            callback = function(bufnr)
              note:write_to_buffer { bufnr = bufnr }
            end,
          }
        else
          log.warn "Aborted"
          return
        end
      end

      return log.err("Failed to resolve file '" .. res.location .. "'")
    end

    if #results == 1 then
      return vim.schedule(function()
        follow_link(results[1])
      end)
    else
      return vim.schedule(function()
        local picker = Obsidian.picker
        if not picker then
          log.err("Found multiple matches to '%s', but no picker is configured", link)
          return
        end

        ---@type obsidian.PickerEntry[]
        local entries = {}
        for _, res in ipairs(results) do
          local icon, icon_hl
          if res.url ~= nil then
            icon, icon_hl = M.get_icon(res.url)
          end
          table.insert(entries, {
            value = res,
            display = res.name,
            filename = res.path and tostring(res.path) or nil,
            icon = icon,
            icon_hl = icon_hl,
          })
        end

        picker:pick(entries, {
          prompt_title = "Follow link",
          callback = function(res)
            follow_link(res)
          end,
        })
      end)
    end
  end)
end
--------------------------
---- Mapping functions ---
--------------------------

---@param direction "next" | "prev"
M.nav_link = function(direction)
  vim.validate("direction", direction, "string", false, "nav_link must be called with a direction")
  local cursor_line, cursor_col = unpack(vim.api.nvim_win_get_cursor(0))
  local Note = require "obsidian.note"

  search.find_links(Note.from_buffer(0), {}, function(matches)
    if direction == "next" then
      for i = 1, #matches do
        local match = matches[i]
        if (match.line > cursor_line) or (cursor_line == match.line and cursor_col < match.start) then
          return vim.api.nvim_win_set_cursor(0, { match.line, match.start })
        end
      end
    end

    if direction == "prev" then
      for i = #matches, 1, -1 do
        local match = matches[i]
        if (match.line < cursor_line) or (cursor_line == match.line and cursor_col > match.start) then
          return vim.api.nvim_win_set_cursor(0, { match.line, match.start })
        end
      end
    end
  end)
end

M.smart_action = function()
  local legacy = Obsidian.opts.legacy_commands
  -- follow link if possible
  if M.cursor_link() then
    return legacy and "<cmd>ObsidianFollowLink<cr>" or "<cmd>Obsidian follow_link<cr>"
  end

  -- show notes with tag if possible
  if M.cursor_tag() then
    return legacy and "<cmd>ObsidianTags<cr>" or "<cmd>Obsidian tags<cr>"
  end

  if M.cursor_heading() then
    return "za"
  end

  -- toggle task if possible
  -- cycles through your custom UI checkboxes, default: [ ] [~] [>] [x]
  return legacy and "<cmd>ObsidianToggleCheckbox<cr>" or "<cmd>Obsidian toggle_checkbox<cr>"
end

return M

local fzf = require "fzf-lua"
local fzf_actions = require "fzf-lua.actions"
local entry_to_file = require("fzf-lua.path").entry_to_file

local Path = require "obsidian.path"
local abc = require "obsidian.abc"
local Picker = require "obsidian.pickers.picker"
local log = require "obsidian.log"

---@param prompt_title string|?
---@return string|?
local function format_prompt(prompt_title)
  if not prompt_title then
    return
  else
    return prompt_title .. " ❯ "
  end
end

---@param keymap string
---@return string
local function format_keymap(keymap)
  keymap = string.lower(keymap)
  keymap = string.gsub(keymap, vim.pesc "<c-", "ctrl-")
  keymap = string.gsub(keymap, vim.pesc ">", "")
  return keymap
end

---@class obsidian.pickers.FzfPicker : obsidian.Picker
local FzfPicker = abc.new_class({
  ---@diagnostic disable-next-line: unused-local
  __tostring = function(self)
    return "FzfPicker()"
  end,
}, Picker)

---@param opts { callback: fun(path: string)|?, no_default_mappings: boolean|?, selection_mappings: obsidian.PickerMappingTable|? }
local function get_path_actions(opts)
  local actions = {
    default = function(selected, fzf_opts)
      if not opts.no_default_mappings then
        fzf_actions.file_edit_or_qf(selected, fzf_opts)
      end

      if opts.callback then
        local path = entry_to_file(selected[1], fzf_opts).path
        opts.callback(path)
      end
    end,
  }

  if opts.selection_mappings then
    for key, mapping in pairs(opts.selection_mappings) do
      actions[format_keymap(key)] = function(selected, fzf_opts)
        local path = entry_to_file(selected[1], fzf_opts).path
        mapping.callback(path)
      end
    end
  end

  return actions
end

---@param display_to_value_map table<string, any>
---@param opts { callback: fun(path: string)|?, allow_multiple: boolean|?, selection_mappings: obsidian.PickerMappingTable|? }
local function get_value_actions(display_to_value_map, opts)
  ---@param allow_multiple boolean|?
  ---@return any[]|?
  local function get_values(selected, allow_multiple)
    if not selected then
      return
    end

    local values = vim.tbl_map(function(k)
      return display_to_value_map[k]
    end, selected)

    values = vim.tbl_filter(function(v)
      return v ~= nil
    end, values)

    if #values > 1 and not allow_multiple then
      log.err "This mapping does not allow multiple entries"
      return
    end

    if #values > 0 then
      return values
    else
      return nil
    end
  end

  local actions = {
    default = function(selected)
      if not opts.callback then
        return
      end

      local values = get_values(selected, opts.allow_multiple)
      if not values then
        return
      end

      opts.callback(unpack(values))
    end,
  }

  if opts.selection_mappings then
    for key, mapping in pairs(opts.selection_mappings) do
      actions[format_keymap(key)] = function(selected)
        local values = get_values(selected, mapping.allow_multiple)
        if not values then
          return
        end

        mapping.callback(unpack(values))
      end
    end
  end

  return actions
end

---@param opts obsidian.PickerFindOpts|? Options.
FzfPicker.find_files = function(self, opts)
  opts = opts or {}

  ---@type obsidian.Path
  local dir = opts.dir and Path.new(opts.dir) or Obsidian.dir

  fzf.files {
    cwd = tostring(dir),
    cmd = table.concat(self:_build_find_cmd(), " "),
    actions = get_path_actions {
      callback = opts.callback,
      no_default_mappings = opts.no_default_mappings,
      selection_mappings = opts.selection_mappings,
    },
    prompt = format_prompt(opts.prompt_title),
  }
end

---@param opts obsidian.PickerGrepOpts|? Options.
FzfPicker.grep = function(self, opts)
  opts = opts and opts or {}

  ---@type obsidian.Path
  local dir = opts.dir and Path:new(opts.dir) or Obsidian.dir
  local cmd = table.concat(self:_build_grep_cmd(), " ")
  local actions = get_path_actions {
    callback = opts.callback,
    no_default_mappings = opts.no_default_mappings,
    selection_mappings = opts.selection_mappings,
  }

  if opts.query and string.len(opts.query) > 0 then
    fzf.grep {
      cwd = tostring(dir),
      search = opts.query,
      cmd = cmd,
      actions = actions,
      prompt = format_prompt(opts.prompt_title),
    }
  else
    fzf.live_grep {
      cwd = tostring(dir),
      cmd = cmd,
      actions = actions,
      prompt = format_prompt(opts.prompt_title),
    }
  end
end

---@param values string[]|obsidian.PickerEntry[]
---@param opts obsidian.PickerPickOpts|? Options.
---@diagnostic disable-next-line: unused-local
FzfPicker.pick = function(self, values, opts)
  self.calling_bufnr = vim.api.nvim_get_current_buf()

  opts = opts or {}

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

  ---@type string[]
  local entries = {}
  for _, value in ipairs(values) do
    if type(value) == "string" then
      display_to_value_map[value] = value
      entries[#entries + 1] = value
    elseif value.valid ~= false then
      local display = self:_make_display(value)
      display_to_value_map[display] = value.value
      entries[#entries + 1] = display
    end
  end

  fzf.fzf_exec(entries, {
    prompt = format_prompt(
      self:_build_prompt { prompt_title = opts.prompt_title, selection_mappings = opts.selection_mappings }
    ),
    actions = get_value_actions(display_to_value_map, {
      callback = opts.callback,
      allow_multiple = opts.allow_multiple,
      selection_mappings = opts.selection_mappings,
    }),
  })
end

return FzfPicker

local log = require "obsidian.log"

local config = {}

---@class obsidian.config
---@field workspaces obsidian.workspace.WorkspaceSpec[]
---@field log_level? integer
---@field notes_subdir? string
---@field templates? obsidian.config.TemplateOpts
---@field new_notes_location? obsidian.config.NewNotesLocation
---@field note_id_func? (fun(title: string|?, path: obsidian.Path|?): string)|?
---@field note_path_func? fun(spec: { id: string, dir: obsidian.Path, title: string|? }): string|obsidian.Path
---@field wiki_link_func? fun(opts: {path: string, label: string, id: string|?}): string
---@field markdown_link_func? fun(opts: {path: string, label: string, id: string|?}): string
---@field preferred_link_style? obsidian.config.LinkStyle
---@field follow_url_func? fun(url: string)
---@field follow_img_func? fun(img: string)
---@field note_frontmatter_func? (fun(note: obsidian.Note): table)
---@field disable_frontmatter? (fun(fname: string?): boolean)|boolean
---@field backlinks? obsidian.config.BacklinkOpts
---@field completion? obsidian.config.CompletionOpts
---@field picker? obsidian.config.PickerOpts
---@field daily_notes? obsidian.config.DailyNotesOpts
---@field sort_by? obsidian.config.SortBy
---@field sort_reversed? boolean
---@field search_max_lines? integer
---@field open_notes_in? obsidian.config.OpenStrategy
---@field ui? obsidian.config.UIOpts | table<string, any>
---@field attachments? obsidian.config.AttachmentsOpts
---@field callbacks? obsidian.config.CallbackConfig
---@field legacy_commands? boolean
---@field statusline? obsidian.config.StatuslineOpts
---@field footer? obsidian.config.FooterOpts
---@field open? obsidian.config.OpenOpts
---@field checkbox? obsidian.config.CheckboxOpts
---@field comment? obsidian.config.CommentOpts

---@class obsidian.config.ClientOpts
---@field dir string|?
---@field workspaces obsidian.workspace.WorkspaceSpec[]|?
---@field log_level integer
---@field notes_subdir string|?
---@field templates obsidian.config.TemplateOpts
---@field new_notes_location obsidian.config.NewNotesLocation
---@field note_id_func (fun(title: string|?, path: obsidian.Path|?): string)|?
---@field note_path_func (fun(spec: { id: string, dir: obsidian.Path, title: string|? }): string|obsidian.Path)|?
---@field wiki_link_func (fun(opts: {path: string, label: string, id: string|?}): string)
---@field markdown_link_func (fun(opts: {path: string, label: string, id: string|?}): string)
---@field preferred_link_style obsidian.config.LinkStyle
---@field follow_url_func fun(url: string)|?
---@field follow_img_func fun(img: string)|?
---@field note_frontmatter_func (fun(note: obsidian.Note): table)|?
---@field disable_frontmatter (fun(fname: string?): boolean)|boolean|?
---@field backlinks obsidian.config.BacklinkOpts
---@field completion obsidian.config.CompletionOpts
---@field picker obsidian.config.PickerOpts
---@field daily_notes obsidian.config.DailyNotesOpts
---@field sort_by obsidian.config.SortBy|?
---@field sort_reversed boolean|?
---@field search_max_lines integer
---@field open_notes_in obsidian.config.OpenStrategy
---@field ui obsidian.config.UIOpts | table<string, any>
---@field attachments obsidian.config.AttachmentsOpts
---@field callbacks obsidian.config.CallbackConfig
---@field legacy_commands boolean
---@field statusline obsidian.config.StatuslineOpts
---@field footer obsidian.config.FooterOpts
---@field open obsidian.config.OpenOpts
---@field checkbox obsidian.config.CheckboxOpts
---@field comment obsidian.config.CommentOpts

---@enum obsidian.config.OpenStrategy
config.OpenStrategy = {
  current = "current",
  vsplit = "vsplit",
  hsplit = "hsplit",
  vsplit_force = "vsplit_force",
  hsplit_force = "hsplit_force",
}

---@enum obsidian.config.SortBy
config.SortBy = {
  path = "path",
  modified = "modified",
  accessed = "accessed",
  created = "created",
}

---@enum obsidian.config.NewNotesLocation
config.NewNotesLocation = {
  current_dir = "current_dir",
  notes_subdir = "notes_subdir",
}

---@enum obsidian.config.LinkStyle
config.LinkStyle = {
  wiki = "wiki",
  markdown = "markdown",
}

---@enum obsidian.config.Picker
config.Picker = {
  telescope = "telescope.nvim",
  fzf_lua = "fzf-lua",
  mini = "mini.pick",
  snacks = "snacks.pick",
}

--- Get defaults.
---
---@return obsidian.config.ClientOpts
config.default = {
  legacy_commands = true,
  workspaces = {},
  log_level = vim.log.levels.INFO,
  notes_subdir = nil,
  new_notes_location = config.NewNotesLocation.current_dir,
  note_id_func = nil,
  wiki_link_func = require("obsidian.builtin").wiki_link_id_prefix,
  markdown_link_func = require("obsidian.builtin").markdown_link,
  preferred_link_style = config.LinkStyle.wiki,
  follow_url_func = vim.ui.open,
  follow_img_func = vim.ui.open,
  note_frontmatter_func = nil,
  disable_frontmatter = false,
  sort_by = "modified",
  sort_reversed = true,
  search_max_lines = 1000,
  open_notes_in = "current",

  ---@class obsidian.config.TemplateOpts
  ---
  ---@field folder string|obsidian.Path|?
  ---@field date_format string|?
  ---@field time_format string|?
  --- A map for custom variables, the key should be the variable and the value a function.
  --- Functions are called with obsidian.TemplateContext objects as their sole parameter.
  --- See: https://github.com/obsidian-nvim/obsidian.nvim/wiki/Template#substitutions
  ---@field substitutions table<string, (fun(ctx: obsidian.TemplateContext):string)|(fun(): string)|string>|?
  ---@field customizations table<string, obsidian.config.CustomTemplateOpts>|?
  templates = {
    folder = nil,
    date_format = nil,
    time_format = nil,
    substitutions = {},

    ---@class obsidian.config.CustomTemplateOpts
    ---
    ---@field notes_subdir? string
    ---@field note_id_func? (fun(title: string|?, path: obsidian.Path|?): string)
    customizations = {},
  },

  ---@class obsidian.config.BacklinkOpts
  ---
  ---@field parse_headers boolean
  backlinks = {
    parse_headers = true,
  },

  ---@class obsidian.config.CompletionOpts
  ---
  ---@field nvim_cmp? boolean
  ---@field blink? boolean
  ---@field min_chars? integer
  ---@field match_case? boolean
  ---@field create_new? boolean
  completion = (function()
    local has_nvim_cmp, _ = pcall(require, "cmp")
    return {
      nvim_cmp = has_nvim_cmp,
      min_chars = 2,
      match_case = true,
      create_new = true,
    }
  end)(),

  ---@class obsidian.config.PickerNoteMappingOpts
  ---
  ---@field new? string
  ---@field insert_link? string

  ---@class obsidian.config.PickerTagMappingOpts
  ---
  ---@field tag_note? string
  ---@field insert_tag? string

  ---@class obsidian.config.PickerOpts
  ---
  ---@field name obsidian.config.Picker|?
  ---@field note_mappings? obsidian.config.PickerNoteMappingOpts
  ---@field tag_mappings? obsidian.config.PickerTagMappingOpts
  picker = {
    name = nil,
    note_mappings = {
      new = "<C-x>",
      insert_link = "<C-l>",
    },
    tag_mappings = {
      tag_note = "<C-x>",
      insert_tag = "<C-l>",
    },
  },

  ---@class obsidian.config.DailyNotesOpts
  ---
  ---@field folder? string
  ---@field date_format? string
  ---@field alias_format? string
  ---@field template? string
  ---@field default_tags? string[]
  ---@field workdays_only? boolean
  daily_notes = {
    folder = nil,
    date_format = nil,
    alias_format = nil,
    default_tags = { "daily-notes" },
    workdays_only = true,
  },

  ---@class obsidian.config.UICharSpec
  ---@field char string
  ---@field hl_group string

  ---@class obsidian.config.CheckboxSpec : obsidian.config.UICharSpec
  ---@field char string
  ---@field hl_group string

  ---@class obsidian.config.UIStyleSpec
  ---@field hl_group string

  ---@class obsidian.config.UIOpts
  ---
  ---@field enable boolean
  ---@field ignore_conceal_warn boolean
  ---@field update_debounce integer
  ---@field max_file_length integer|?
  ---@field checkboxes table<string, obsidian.config.CheckboxSpec>
  ---@field bullets obsidian.config.UICharSpec|?
  ---@field external_link_icon obsidian.config.UICharSpec
  ---@field reference_text obsidian.config.UIStyleSpec
  ---@field highlight_text obsidian.config.UIStyleSpec
  ---@field tags obsidian.config.UIStyleSpec
  ---@field block_ids obsidian.config.UIStyleSpec
  ---@field hl_groups table<string, table>
  ui = {
    enable = true,
    ignore_conceal_warn = false,
    update_debounce = 200,
    max_file_length = 5000,
    checkboxes = {
      [" "] = { char = "󰄱", hl_group = "obsidiantodo" },
      ["~"] = { char = "󰰱", hl_group = "obsidiantilde" },
      ["!"] = { char = "", hl_group = "obsidianimportant" },
      [">"] = { char = "", hl_group = "obsidianrightarrow" },
      ["x"] = { char = "", hl_group = "obsidiandone" },
    },
    bullets = { char = "•", hl_group = "ObsidianBullet" },
    external_link_icon = { char = "", hl_group = "ObsidianExtLinkIcon" },
    reference_text = { hl_group = "ObsidianRefText" },
    highlight_text = { hl_group = "ObsidianHighlightText" },
    tags = { hl_group = "ObsidianTag" },
    block_ids = { hl_group = "ObsidianBlockID" },
    hl_groups = {
      ObsidianTodo = { bold = true, fg = "#f78c6c" },
      ObsidianDone = { bold = true, fg = "#89ddff" },
      ObsidianRightArrow = { bold = true, fg = "#f78c6c" },
      ObsidianTilde = { bold = true, fg = "#ff5370" },
      ObsidianImportant = { bold = true, fg = "#d73128" },
      ObsidianBullet = { bold = true, fg = "#89ddff" },
      ObsidianRefText = { underline = true, fg = "#c792ea" },
      ObsidianExtLinkIcon = { fg = "#c792ea" },
      ObsidianTag = { italic = true, fg = "#89ddff" },
      ObsidianBlockID = { italic = true, fg = "#89ddff" },
      ObsidianHighlightText = { bg = "#75662e" },
    },
  },

  ---@class obsidian.config.AttachmentsOpts
  ---
  ---Default folder to save images to, relative to the vault root (/) or current dir (.), see https://github.com/obsidian-nvim/obsidian.nvim/wiki/Images#change-image-save-location
  ---@field img_folder? string
  ---
  ---Default name for pasted images
  ---@field img_name_func? fun(): string
  ---
  ---Default text to insert for pasted images
  ---@field img_text_func? fun(path: obsidian.Path): string
  ---
  ---Whether to confirm the paste or not. Defaults to true.
  ---@field confirm_img_paste? boolean
  attachments = {
    img_folder = "assets/imgs",
    img_text_func = require("obsidian.builtin").img_text_func,
    img_name_func = function()
      return string.format("Pasted image %s", os.date "%Y%m%d%H%M%S")
    end,
    confirm_img_paste = true,
  },

  ---@class obsidian.config.CallbackConfig
  ---
  ---Runs right after the `obsidian.Client` is initialized.
  ---@field post_setup? fun(client: obsidian.Client)
  ---
  ---Runs when entering a note buffer.
  ---@field enter_note? fun(client: obsidian.Client, note: obsidian.Note)
  ---
  ---Runs when leaving a note buffer.
  ---@field leave_note? fun(client: obsidian.Client, note: obsidian.Note)
  ---
  ---Runs right before writing a note buffer.
  ---@field pre_write_note? fun(client: obsidian.Client, note: obsidian.Note)
  ---
  ---Runs anytime the workspace is set/changed.
  ---@field post_set_workspace? fun(client: obsidian.Client, workspace: obsidian.Workspace)
  callbacks = {},

  ---@class obsidian.config.StatuslineOpts
  ---
  ---@field format? string
  ---@field enabled? boolean
  statusline = {
    format = "{{backlinks}} backlinks  {{properties}} properties  {{words}} words  {{chars}} chars",
    enabled = true,
  },

  ---@class obsidian.config.FooterOpts
  ---
  ---@field enabled? boolean
  ---@field format? string
  ---@field hl_group? string
  ---@field separator? string|false Set false to disable separator; set an empty string to insert a blank line separator.
  footer = {
    enabled = true,
    format = "{{backlinks}} backlinks  {{properties}} properties  {{words}} words  {{chars}} chars",
    hl_group = "Comment",
    separator = string.rep("-", 80),
  },

  ---@class obsidian.config.OpenOpts
  ---
  ---Opens the file with current line number
  ---@field use_advanced_uri? boolean
  ---
  ---Function to do the opening, default to vim.ui.open
  ---@field func? fun(uri: string)
  open = {
    use_advanced_uri = false,
    func = vim.ui.open,
  },

  ---@class obsidian.config.CheckboxOpts
  ---
  ---@field enabled? boolean
  ---
  ---Order of checkbox state chars, e.g. { " ", "x" }
  ---@field order? string[]
  ---
  ---Whether to create new checkbox on paragraphs
  ---@field create_new? boolean
  checkbox = {
    enabled = true,
    create_new = true,
    order = { " ", "~", "!", ">", "x" },
  },

  ---@class obsidian.config.CommentOpts
  ---@field enabled boolean
  comment = {
    enabled = false,
  },
}

local tbl_override = function(defaults, overrides)
  local out = vim.tbl_extend("force", defaults, overrides)
  for k, v in pairs(out) do
    if v == vim.NIL then
      out[k] = nil
    end
  end
  return out
end

local function deprecate(name, alternative, version)
  vim.deprecate(name, alternative, version, "obsidian.nvim", false)
end

--- Normalize options.
---
---@param opts table<string, any>
---@param defaults obsidian.config.ClientOpts|?
---
---@return obsidian.config.ClientOpts
config.normalize = function(opts, defaults)
  local builtin = require "obsidian.builtin"
  local util = require "obsidian.util"

  if not defaults then
    defaults = config.default
  end

  -------------------------------------------------------------------------------------
  -- Rename old fields for backwards compatibility and warn about deprecated fields. --
  -------------------------------------------------------------------------------------

  if opts.ui and opts.ui.tick then
    opts.ui.update_debounce = opts.ui.tick
    opts.ui.tick = nil
  end

  if not opts.picker then
    opts.picker = {}
    if opts.finder then
      opts.picker.name = opts.finder
      opts.finder = nil
    end
    if opts.finder_mappings then
      opts.picker.note_mappings = opts.finder_mappings
      opts.finder_mappings = nil
    end
    if opts.picker.mappings and not opts.picker.note_mappings then
      opts.picker.note_mappings = opts.picker.mappings
      opts.picker.mappings = nil
    end
  end

  if opts.wiki_link_func == nil and opts.completion ~= nil then
    local warn = false

    if opts.completion.prepend_note_id then
      opts.wiki_link_func = builtin.wiki_link_id_prefix
      opts.completion.prepend_note_id = nil
      warn = true
    elseif opts.completion.prepend_note_path then
      opts.wiki_link_func = builtin.wiki_link_path_prefix
      opts.completion.prepend_note_path = nil
      warn = true
    elseif opts.completion.use_path_only then
      opts.wiki_link_func = builtin.wiki_link_path_only
      opts.completion.use_path_only = nil
      warn = true
    end

    if warn then
      log.warn_once(
        "The config options 'completion.prepend_note_id', 'completion.prepend_note_path', and 'completion.use_path_only' "
          .. "are deprecated. Please use 'wiki_link_func' instead.\n"
          .. "See https://github.com/epwalsh/obsidian.nvim/pull/406"
      )
    end
  end

  if opts.wiki_link_func == "prepend_note_id" then
    opts.wiki_link_func = builtin.wiki_link_id_prefix
  elseif opts.wiki_link_func == "prepend_note_path" then
    opts.wiki_link_func = builtin.wiki_link_path_prefix
  elseif opts.wiki_link_func == "use_path_only" then
    opts.wiki_link_func = builtin.wiki_link_path_only
  elseif opts.wiki_link_func == "use_alias_only" then
    opts.wiki_link_func = builtin.wiki_link_alias_only
  elseif type(opts.wiki_link_func) == "string" then
    error(string.format("invalid option '%s' for 'wiki_link_func'", opts.wiki_link_func))
  end

  if opts.completion ~= nil and opts.completion.preferred_link_style ~= nil then
    opts.preferred_link_style = opts.completion.preferred_link_style
    opts.completion.preferred_link_style = nil
    log.warn_once(
      "The config option 'completion.preferred_link_style' is deprecated, please use the top-level "
        .. "'preferred_link_style' instead."
    )
  end

  if opts.completion ~= nil and opts.completion.new_notes_location ~= nil then
    opts.new_notes_location = opts.completion.new_notes_location
    opts.completion.new_notes_location = nil
    log.warn_once(
      "The config option 'completion.new_notes_location' is deprecated, please use the top-level "
        .. "'new_notes_location' instead."
    )
  end

  if opts.detect_cwd ~= nil then
    opts.detect_cwd = nil
    log.warn_once(
      "The 'detect_cwd' field is deprecated and no longer has any affect.\n"
        .. "See https://github.com/epwalsh/obsidian.nvim/pull/366 for more details."
    )
  end

  if opts.open_app_foreground ~= nil then
    opts.open_app_foreground = nil
    log.warn_once [[The config option 'open_app_foreground' is deprecated, please use the `func` field in `open` module:

```lua
{
  open = {
    func = function(uri)
      vim.ui.open(uri, { cmd = { "open", "-a", "/Applications/Obsidian.app" } })
    end
  }
}
```]]
  end

  if opts.use_advanced_uri ~= nil then
    opts.use_advanced_uri = nil
    log.warn_once [[The config option 'use_advanced_uri' is deprecated, please use in `open` module instead]]
  end

  if opts.overwrite_mappings ~= nil then
    log.warn_once "The 'overwrite_mappings' config option is deprecated and no longer has any affect."
    opts.overwrite_mappings = nil
  end

  if opts.mappings ~= nil then
    log.warn_once [[The 'mappings' config option is deprecated and no longer has any affect.
See: https://github.com/obsidian-nvim/obsidian.nvim/wiki/Keymaps]]
    opts.overwrite_mappings = nil
  end

  if opts.tags ~= nil then
    log.warn_once "The 'tags' config option is deprecated and no longer has any affect."
    opts.tags = nil
  end

  if opts.templates and opts.templates.subdir then
    opts.templates.folder = opts.templates.subdir
    opts.templates.subdir = nil
  end

  if opts.image_name_func then
    if opts.attachments == nil then
      opts.attachments = {}
    end
    opts.attachments.img_name_func = opts.image_name_func
    opts.image_name_func = nil
  end

  if opts.statusline and opts.statusline.enabled then
    deprecate("statusline.{enabled,format} and vim.g.obsidian", "footer.{enabled,format}", "4.0")
  end

  --------------------------
  -- Merge with defaults. --
  --------------------------

  ---@type obsidian.config.ClientOpts
  opts = tbl_override(defaults, opts)

  opts.backlinks = tbl_override(defaults.backlinks, opts.backlinks)
  opts.completion = tbl_override(defaults.completion, opts.completion)
  opts.picker = tbl_override(defaults.picker, opts.picker)
  opts.daily_notes = tbl_override(defaults.daily_notes, opts.daily_notes)
  opts.templates = tbl_override(defaults.templates, opts.templates)
  opts.ui = tbl_override(defaults.ui, opts.ui)
  opts.attachments = tbl_override(defaults.attachments, opts.attachments)
  opts.statusline = tbl_override(defaults.statusline, opts.statusline)
  opts.footer = tbl_override(defaults.footer, opts.footer)
  opts.open = tbl_override(defaults.open, opts.open)
  opts.checkbox = tbl_override(defaults.checkbox, opts.checkbox)
  opts.comment = tbl_override(defaults.comment, opts.comment)

  ---------------
  -- Validate. --
  ---------------

  if opts.legacy_commands then
    deprecate(
      "legacy_commands",
      [[move from commands like `ObsidianBacklinks` to `Obsidian backlinks`
and set `opts.legacy_commands` to false to get rid of this warning.
see https://github.com/obsidian-nvim/obsidian.nvim/wiki/Commands for details.
    ]],
      "4.0"
    )
  end

  if opts.sort_by ~= nil and not vim.tbl_contains(vim.tbl_values(config.SortBy), opts.sort_by) then
    error("Invalid 'sort_by' option '" .. opts.sort_by .. "' in obsidian.nvim config.")
  end

  if not util.islist(opts.workspaces) then
    error "Invalid obsidian.nvim config, the 'config.workspaces' should be an array/list."
  elseif vim.tbl_isempty(opts.workspaces) then
    error "At least one workspace is required!\nPlease specify a workspace "
  end

  for i, workspace in ipairs(opts.workspaces) do
    local path = type(workspace.path) == "function" and workspace.path() or workspace.path
    ---@cast path -function
    opts.workspaces[i].path = vim.fn.resolve(vim.fs.normalize(path))
  end

  -- Convert dir to workspace format.
  if opts.dir ~= nil then
    table.insert(opts.workspaces, 1, { path = opts.dir })
  end

  return opts
end

return config

local Path = require "obsidian.path"
local util = require "obsidian.util"
local iter = vim.iter
local run_job_async = require("obsidian.async").run_job_async
local compat = require "obsidian.compat"
local log = require "obsidian.log"
local block_on = require("obsidian.async").block_on

local M = {}

M._BASE_CMD = { "rg", "--no-config", "--type=md" }
M._SEARCH_CMD = compat.flatten { M._BASE_CMD, "--json" }
M._FIND_CMD = compat.flatten { M._BASE_CMD, "--files" }

---@enum obsidian.search.RefTypes
M.RefTypes = {
  WikiWithAlias = "WikiWithAlias",
  Wiki = "Wiki",
  Markdown = "Markdown",
  NakedUrl = "NakedUrl",
  FileUrl = "FileUrl",
  MailtoUrl = "MailtoUrl",
  Tag = "Tag",
  BlockID = "BlockID",
  Highlight = "Highlight",
}

---@enum obsidian.search.Patterns
M.Patterns = {
  -- Tags
  TagCharsOptional = "[A-Za-z0-9_/-]*",
  TagCharsRequired = "[A-Za-z]+[A-Za-z0-9_/-]*[A-Za-z0-9]+", -- assumes tag is at least 2 chars
  Tag = "#[A-Za-z]+[A-Za-z0-9_/-]*[A-Za-z0-9]+",

  -- Miscellaneous
  Highlight = "==[^=]+==", -- ==text==

  -- References
  WikiWithAlias = "%[%[[^][%|]+%|[^%]]+%]%]", -- [[xxx|yyy]]
  Wiki = "%[%[[^][%|]+%]%]", -- [[xxx]]
  Markdown = "%[[^][]+%]%([^%)]+%)", -- [yyy](xxx)
  NakedUrl = "https?://[a-zA-Z0-9._-@]+[a-zA-Z0-9._#/=&?:+%%-@]+[a-zA-Z0-9/]", -- https://xyz.com
  FileUrl = "file:/[/{2}]?.*", -- file:///
  MailtoUrl = "mailto:.*", -- mailto:emailaddress
  BlockID = util.BLOCK_PATTERN .. "$", -- ^hello-world
}

---@type table<obsidian.search.RefTypes, { ignore_if_escape_prefix: boolean|? }>
M.PatternConfig = {
  [M.RefTypes.Tag] = { ignore_if_escape_prefix = true },
}

--- Find all matches of a pattern
---
---@param s string
---@param pattern_names obsidian.search.RefTypes[]
---
---@return { [1]: integer, [2]: integer, [3]: obsidian.search.RefTypes }[]
M.find_matches = function(s, pattern_names)
  -- First find all inline code blocks so we can skip reference matches inside of those.
  local inline_code_blocks = {}
  for m_start, m_end in util.gfind(s, "`[^`]*`") do
    inline_code_blocks[#inline_code_blocks + 1] = { m_start, m_end }
  end

  local matches = {}
  for pattern_name in iter(pattern_names) do
    local pattern = M.Patterns[pattern_name]
    local pattern_cfg = M.PatternConfig[pattern_name]
    local search_start = 1
    while search_start < #s do
      local m_start, m_end = string.find(s, pattern, search_start)
      if m_start ~= nil and m_end ~= nil then
        -- Check if we're inside a code block.
        local inside_code_block = false
        for code_block_boundary in iter(inline_code_blocks) do
          if code_block_boundary[1] < m_start and m_end < code_block_boundary[2] then
            inside_code_block = true
            break
          end
        end

        if not inside_code_block then
          -- Check if this match overlaps with any others (e.g. a naked URL match would be contained in
          -- a markdown URL).
          local overlap = false
          for match in iter(matches) do
            if (match[1] <= m_start and m_start <= match[2]) or (match[1] <= m_end and m_end <= match[2]) then
              overlap = true
              break
            end
          end

          -- Check if we should skip to an escape sequence before the pattern.
          local skip_due_to_escape = false
          if
            pattern_cfg ~= nil
            and pattern_cfg.ignore_if_escape_prefix
            and string.sub(s, m_start - 1, m_start - 1) == [[\]]
          then
            skip_due_to_escape = true
          end

          if not overlap and not skip_due_to_escape then
            matches[#matches + 1] = { m_start, m_end, pattern_name }
          end
        end

        search_start = m_end
      else
        break
      end
    end
  end

  -- Sort results by position.
  table.sort(matches, function(a, b)
    return a[1] < b[1]
  end)

  return matches
end

--- Find inline highlights
---
---@param s string
---
---@return { [1]: integer, [2]: integer, [3]: obsidian.search.RefTypes }[]
M.find_highlight = function(s)
  local matches = {}
  for match in iter(M.find_matches(s, { M.RefTypes.Highlight })) do
    -- Remove highlights that begin/end with whitespace
    local match_start, match_end, _ = unpack(match)
    local text = string.sub(s, match_start + 2, match_end - 2)
    if vim.trim(text) == text then
      matches[#matches + 1] = match
    end
  end
  return matches
end

---@class obsidian.search.FindRefsOpts
---
---@field include_naked_urls boolean|?
---@field include_tags boolean|?
---@field include_file_urls boolean|?
---@field include_block_ids boolean|?

--- Find refs and URLs.
---@param s string the string to search
---@param opts obsidian.search.FindRefsOpts|?
---
---@return { [1]: integer, [2]: integer, [3]: obsidian.search.RefTypes }[]
M.find_refs = function(s, opts)
  opts = opts and opts or {}

  local pattern_names = { M.RefTypes.WikiWithAlias, M.RefTypes.Wiki, M.RefTypes.Markdown }
  if opts.include_naked_urls then
    pattern_names[#pattern_names + 1] = M.RefTypes.NakedUrl
  end
  if opts.include_tags then
    pattern_names[#pattern_names + 1] = M.RefTypes.Tag
  end
  if opts.include_file_urls then
    pattern_names[#pattern_names + 1] = M.RefTypes.FileUrl
  end
  if opts.include_block_ids then
    pattern_names[#pattern_names + 1] = M.RefTypes.BlockID
  end

  return M.find_matches(s, pattern_names)
end

--- Find all tags in a string.
---@param s string the string to search
---
---@return {[1]: integer, [2]: integer, [3]: obsidian.search.RefTypes}[]
M.find_tags = function(s)
  local matches = {}
  for match in iter(M.find_matches(s, { M.RefTypes.Tag })) do
    local st, ed, m_type = unpack(match)
    local match_string = s:sub(st, ed)
    if m_type == M.RefTypes.Tag and not util.is_hex_color(match_string) then
      matches[#matches + 1] = match
    end
  end
  return matches
end

--- Replace references of the form '[[xxx|xxx]]', '[[xxx]]', or '[xxx](xxx)' with their title.
---
---@param s string
---
---@return string
M.replace_refs = function(s)
  local out, _ = string.gsub(s, "%[%[[^%|%]]+%|([^%]]+)%]%]", "%1")
  out, _ = out:gsub("%[%[([^%]]+)%]%]", "%1")
  out, _ = out:gsub("%[([^%]]+)%]%([^%)]+%)", "%1")
  return out
end

--- Find all refs in a string and replace with their titles.
---
---@param s string
--
---@return string
---@return table
---@return string[]
M.find_and_replace_refs = function(s)
  local pieces = {}
  local refs = {}
  local is_ref = {}
  local matches = M.find_refs(s)
  local last_end = 1
  for _, match in pairs(matches) do
    local m_start, m_end, _ = unpack(match)
    assert(type(m_start) == "number")
    if last_end < m_start then
      table.insert(pieces, string.sub(s, last_end, m_start - 1))
      table.insert(is_ref, false)
    end
    local ref_str = string.sub(s, m_start, m_end)
    table.insert(pieces, M.replace_refs(ref_str))
    table.insert(refs, ref_str)
    table.insert(is_ref, true)
    last_end = m_end + 1
  end

  local indices = {}
  local length = 0
  for i, piece in ipairs(pieces) do
    local i_end = length + string.len(piece)
    if is_ref[i] then
      table.insert(indices, { length + 1, i_end })
    end
    length = i_end
  end

  return table.concat(pieces, ""), indices, refs
end

--- Find all code block boundaries in a list of lines.
---
---@param lines string[]
---
---@return { [1]: integer, [2]: integer }[]
M.find_code_blocks = function(lines)
  ---@type { [1]: integer, [2]: integer }[]
  local blocks = {}
  ---@type integer|?
  local start_idx
  for i, line in ipairs(lines) do
    if string.match(line, "^%s*```.*```%s*$") then
      table.insert(blocks, { i, i })
      start_idx = nil
    elseif string.match(line, "^%s*```") then
      if start_idx ~= nil then
        table.insert(blocks, { start_idx, i })
        start_idx = nil
      else
        start_idx = i
      end
    end
  end
  return blocks
end

---@class obsidian.search.SearchOpts
---
---@field sort_by obsidian.config.SortBy|?
---@field sort_reversed boolean|?
---@field fixed_strings boolean|?
---@field ignore_case boolean|?
---@field smart_case boolean|?
---@field exclude string[]|? paths to exclude
---@field max_count_per_file integer|?
---@field escape_path boolean|?
---@field include_non_markdown boolean|?

local SearchOpts = {}
M.SearchOpts = SearchOpts

SearchOpts.as_tbl = function(self)
  local fields = {}
  for k, v in pairs(self) do
    if not vim.startswith(k, "__") then
      fields[k] = v
    end
  end
  return fields
end

---@param one obsidian.search.SearchOpts|table
---@param other obsidian.search.SearchOpts|table
---@return obsidian.search.SearchOpts
SearchOpts.merge = function(one, other)
  return vim.tbl_extend("force", SearchOpts.as_tbl(one), SearchOpts.as_tbl(other))
end

---@param opts obsidian.search.SearchOpts
---@param path string
SearchOpts.add_exclude = function(opts, path)
  if opts.exclude == nil then
    opts.exclude = {}
  end
  opts.exclude[#opts.exclude + 1] = path
end

---@param opts obsidian.search.SearchOpts
---@return string[]
SearchOpts.to_ripgrep_opts = function(opts)
  local ret = {}

  if opts.sort_by ~= nil then
    local sort = "sortr" -- default sort is reverse
    if opts.sort_reversed == false then
      sort = "sort"
    end
    ret[#ret + 1] = "--" .. sort .. "=" .. opts.sort_by
  end

  if opts.fixed_strings then
    ret[#ret + 1] = "--fixed-strings"
  end

  if opts.ignore_case then
    ret[#ret + 1] = "--ignore-case"
  end

  if opts.smart_case then
    ret[#ret + 1] = "--smart-case"
  end

  if opts.exclude ~= nil then
    assert(type(opts.exclude) == "table")
    for path in iter(opts.exclude) do
      ret[#ret + 1] = "-g!" .. path
    end
  end

  if opts.max_count_per_file ~= nil then
    ret[#ret + 1] = "-m=" .. opts.max_count_per_file
  end

  return ret
end

---@param dir string|obsidian.Path
---@param term string|string[]
---@param opts obsidian.search.SearchOpts|?
---
---@return string[]
M.build_search_cmd = function(dir, term, opts)
  opts = opts and opts or {}

  local search_terms
  if type(term) == "string" then
    search_terms = { "-e", term }
  else
    search_terms = {}
    for t in iter(term) do
      search_terms[#search_terms + 1] = "-e"
      search_terms[#search_terms + 1] = t
    end
  end

  local path = tostring(Path.new(dir):resolve { strict = true })
  if opts.escape_path then
    path = assert(vim.fn.fnameescape(path))
  end

  return compat.flatten {
    M._SEARCH_CMD,
    SearchOpts.to_ripgrep_opts(opts),
    search_terms,
    path,
  }
end

--- Build the 'rg' command for finding files.
---
---@param path string|?
---@param term string|?
---@param opts obsidian.search.SearchOpts|?
---
---@return string[]
M.build_find_cmd = function(path, term, opts)
  opts = opts and opts or {}

  local additional_opts = {}

  if term ~= nil then
    if opts.include_non_markdown then
      term = "*" .. term .. "*"
    elseif not vim.endswith(term, ".md") then
      term = "*" .. term .. "*.md"
    else
      term = "*" .. term
    end
    additional_opts[#additional_opts + 1] = "-g"
    additional_opts[#additional_opts + 1] = term
  end

  if opts.ignore_case then
    additional_opts[#additional_opts + 1] = "--glob-case-insensitive"
  end

  if path ~= nil and path ~= "." then
    if opts.escape_path then
      path = assert(vim.fn.fnameescape(tostring(path)))
    end
    additional_opts[#additional_opts + 1] = path
  end

  return compat.flatten { M._FIND_CMD, SearchOpts.to_ripgrep_opts(opts), additional_opts }
end

--- Build the 'rg' grep command for pickers.
---
---@param opts obsidian.search.SearchOpts|?
---
---@return string[]
M.build_grep_cmd = function(opts)
  opts = opts and opts or {}

  return compat.flatten {
    M._BASE_CMD,
    SearchOpts.to_ripgrep_opts(opts),
    "--column",
    "--line-number",
    "--no-heading",
    "--with-filename",
    "--color=never",
  }
end

---@class MatchPath
---
---@field text string

---@class MatchText
---
---@field text string

---@class SubMatch
---
---@field match MatchText
---@field start integer
---@field end integer

---@class MatchData
---
---@field path MatchPath
---@field lines MatchText
---@field line_number integer 0-indexed
---@field absolute_offset integer
---@field submatches SubMatch[]

--- Search markdown files in a directory for a given term. Each match is passed to the `on_match` callback.
---
---@param dir string|obsidian.Path
---@param term string|string[]
---@param opts obsidian.search.SearchOpts|?
---@param on_match fun(match: MatchData)
---@param on_exit fun(exit_code: integer)|?
M.search_async = function(dir, term, opts, on_match, on_exit)
  local cmd = M.build_search_cmd(dir, term, opts)
  run_job_async(cmd, function(line)
    local data = vim.json.decode(line)
    if data["type"] == "match" then
      local match_data = data.data
      on_match(match_data)
    end
  end, function(code)
    if on_exit ~= nil then
      on_exit(code)
    end
  end)
end

--- Find markdown files in a directory matching a given term. Each matching path is passed to the `on_match` callback.
---
---@param dir string|obsidian.Path
---@param term string
---@param opts obsidian.search.SearchOpts|?
---@param on_match fun(path: string)
---@param on_exit fun(exit_code: integer)|?
M.find_async = function(dir, term, opts, on_match, on_exit)
  local norm_dir = Path.new(dir):resolve { strict = true }
  local cmd = M.build_find_cmd(tostring(norm_dir), term, opts)
  run_job_async(cmd, on_match, function(code)
    if on_exit ~= nil then
      on_exit(code)
    end
  end)
end

local search_defualts = {
  sort = false,
  include_templates = false,
  ignore_case = false,
}

---@param opts obsidian.SearchOpts|boolean|?
---@param additional_opts obsidian.search.SearchOpts|?
---
---@return obsidian.search.SearchOpts
---
---@private
local _prepare_search_opts = function(opts, additional_opts)
  opts = opts or search_defualts

  local search_opts = {}

  if opts.sort then
    search_opts.sort_by = Obsidian.opts.sort_by
    search_opts.sort_reversed = Obsidian.opts.sort_reversed
  end

  if not opts.include_templates and Obsidian.opts.templates ~= nil and Obsidian.opts.templates.folder ~= nil then
    M.SearchOpts.add_exclude(search_opts, tostring(Obsidian.opts.templates.folder))
  end

  if opts.ignore_case then
    search_opts.ignore_case = true
  end

  if additional_opts ~= nil then
    search_opts = M.SearchOpts.merge(search_opts, additional_opts)
  end

  return search_opts
end

---@param term string
---@param search_opts obsidian.SearchOpts|boolean|?
---@param find_opts obsidian.SearchOpts|boolean|?
---@param callback fun(path: obsidian.Path)
---@param exit_callback fun(paths: obsidian.Path[])
local _search_async = function(term, search_opts, find_opts, callback, exit_callback)
  local found = {}
  local result = {}
  local cmds_done = 0

  local function dedup_send(path)
    local key = tostring(path:resolve { strict = true })
    if not found[key] then
      found[key] = true
      result[#result + 1] = path
      callback(path)
    end
  end

  local function on_search_match(content_match)
    local path = Path.new(content_match.path.text)
    dedup_send(path)
  end

  local function on_find_match(path_match)
    local path = Path.new(path_match)
    dedup_send(path)
  end

  local function on_exit()
    cmds_done = cmds_done + 1
    if cmds_done == 2 then
      exit_callback(result)
    end
  end

  M.search_async(
    Obsidian.dir,
    term,
    _prepare_search_opts(search_opts, { fixed_strings = true, max_count_per_file = 1 }),
    on_search_match,
    on_exit
  )

  M.find_async(Obsidian.dir, term, _prepare_search_opts(find_opts, { ignore_case = true }), on_find_match, on_exit)
end

--- An async version of `find_notes()` that runs the callback with an array of all matching notes.
---
---@param term string The term to search for
---@param callback fun(notes: obsidian.Note[])
---@param opts { search: obsidian.SearchOpts|?, notes: obsidian.note.LoadOpts|? }|?
M.find_notes_async = function(term, callback, opts)
  opts = opts or {}
  opts.notes = opts.notes or {}
  if not opts.notes.max_lines then
    opts.notes.max_lines = Obsidian.opts.search_max_lines
  end

  ---@type table<string, integer>
  local paths = {}
  local num_results = 0
  local err_count = 0
  local first_err
  local first_err_path
  local notes = {}
  local Note = require "obsidian.note"

  ---@param path obsidian.Path
  local function on_path(path)
    local ok, res = pcall(Note.from_file, path, opts.notes)

    if ok then
      num_results = num_results + 1
      paths[tostring(path)] = num_results
      notes[#notes + 1] = res
    else
      err_count = err_count + 1
      if first_err == nil then
        first_err = res
        first_err_path = path
      end
    end
  end

  local on_exit = function()
    -- Then sort by original order.
    table.sort(notes, function(a, b)
      return paths[tostring(a.path)] < paths[tostring(b.path)]
    end)

    -- Check for errors.
    if first_err ~= nil and first_err_path ~= nil then
      log.err(
        "%d error(s) occurred during search. First error from note at '%s':\n%s",
        err_count,
        first_err_path,
        first_err
      )
    end

    callback(notes)
  end

  _search_async(term, opts.search, nil, on_path, on_exit)
end

M.find_notes = function(term, opts)
  opts = opts or {}
  opts.timeout = opts.timeout or 1000
  return block_on(function(cb)
    return M.find_notes_async(term, cb, { search = opts.search })
  end, opts.timeout)
end

---@param query string
---@param callback fun(results: obsidian.Note[])
---@param opts { notes: obsidian.note.LoadOpts|? }|?
---
---@return obsidian.Note|?
local _resolve_note_async = function(query, callback, opts)
  opts = opts or {}
  opts.notes = opts.notes or {}
  if not opts.notes.max_lines then
    opts.notes.max_lines = Obsidian.opts.search_max_lines
  end
  local Note = require "obsidian.note"

  -- Autocompletion for command args will have this format.
  local note_path, count = string.gsub(query, "^.*  ", "")
  if count > 0 then
    ---@type obsidian.Path
    ---@diagnostic disable-next-line: assign-type-mismatch
    local full_path = Obsidian.dir / note_path
    callback { Note.from_file(full_path, opts.notes) }
  end

  -- Query might be a path.
  local fname = query
  if not vim.endswith(fname, ".md") then
    fname = fname .. ".md"
  end

  local paths_to_check = { Path.new(fname), Obsidian.dir / fname }

  if Obsidian.opts.notes_subdir ~= nil then
    paths_to_check[#paths_to_check + 1] = Obsidian.dir / Obsidian.opts.notes_subdir / fname
  end

  if Obsidian.opts.daily_notes.folder ~= nil then
    paths_to_check[#paths_to_check + 1] = Obsidian.dir / Obsidian.opts.daily_notes.folder / fname
  end

  if Obsidian.buf_dir ~= nil then
    paths_to_check[#paths_to_check + 1] = Obsidian.buf_dir / fname
  end

  for _, path in pairs(paths_to_check) do
    if path:is_file() then
      return callback { Note.from_file(path, opts.notes) }
    end
  end

  M.find_notes_async(query, function(results)
    local query_lwr = string.lower(query)

    -- We'll gather both exact matches (of ID, filename, and aliases) and fuzzy matches.
    -- If we end up with any exact matches, we'll return those. Otherwise we fall back to fuzzy
    -- matches.
    ---@type obsidian.Note[]
    local exact_matches = {}
    ---@type obsidian.Note[]
    local fuzzy_matches = {}

    for note in iter(results) do
      ---@cast note obsidian.Note

      local reference_ids = note:reference_ids { lowercase = true }

      -- Check for exact match.
      if vim.list_contains(reference_ids, query_lwr) then
        table.insert(exact_matches, note)
      else
        -- Fall back to fuzzy match.
        for ref_id in iter(reference_ids) do
          if util.string_contains(ref_id, query_lwr) then
            table.insert(fuzzy_matches, note)
            break
          end
        end
      end
    end

    if #exact_matches > 0 then
      return callback(exact_matches)
    else
      return callback(fuzzy_matches)
    end
  end, { search = { sort = true, ignore_case = true }, notes = opts.notes })
end

--- Resolve a note, opens a picker to choose a single note when there are multiple matches.
---
---@param query string
---@param callback fun(obsidian.Note)
---@param opts { notes: obsidian.note.LoadOpts|?, prompt_title: string|?, pick: boolean }|?
---
---@return obsidian.Note|?
M.resolve_note_async = function(query, callback, opts)
  opts = opts or {}
  opts.pick = vim.F.if_nil(opts.pick, true)

  _resolve_note_async(query, function(notes)
    if opts.pick then
      if #notes == 0 then
        log.err("No notes matching '%s'", query)
        return
      elseif #notes == 1 then
        return callback(notes[1])
      end

      -- Fall back to picker.
      vim.schedule(function()
        -- Otherwise run the preferred picker to search for notes.
        local picker = Obsidian.picker
        if not picker then
          log.err("Found multiple notes matching '%s', but no picker is configured", query)
          return
        end

        picker:pick_note(notes, {
          prompt_title = opts.prompt_title,
          callback = callback,
        })
      end)
    else
      callback(notes)
    end
  end, { notes = opts.notes })
end

---@class obsidian.ResolveLinkResult
---
---@field location string
---@field name string
---@field link_type obsidian.search.RefTypes
---@field path obsidian.Path|?
---@field note obsidian.Note|?
---@field url string|?
---@field line integer|?
---@field col integer|?
---@field anchor obsidian.note.HeaderAnchor|?
---@field block obsidian.note.Block|?

--- Resolve a link.
---
---@param link string
---@param callback fun(results: obsidian.ResolveLinkResult[])
M.resolve_link_async = function(link, callback)
  local Note = require "obsidian.note"

  local location, name, link_type
  location, name, link_type = util.parse_link(link, { include_naked_urls = true, include_file_urls = true })

  if location == nil or name == nil or link_type == nil then
    return callback {}
  end

  ---@type obsidian.ResolveLinkResult
  local res = { location = location, name = name, link_type = link_type }

  if util.is_url(location) then
    res.url = location
    return callback { res }
  end

  -- The Obsidian app will follow URL-encoded links, so we should to.
  location = vim.uri_decode(location)

  -- Remove block links from the end if there are any.
  -- TODO: handle block links.
  ---@type string|?
  local block_link
  location, block_link = util.strip_block_links(location)

  -- Remove anchor links from the end if there are any.
  ---@type string|?
  local anchor_link
  location, anchor_link = util.strip_anchor_links(location)

  --- Finalize the `obsidian.ResolveLinkResult` for a note while resolving block or anchor link to line.
  ---
  ---@param note obsidian.Note
  ---@return obsidian.ResolveLinkResult
  local function finalize_result(note)
    ---@type integer|?, obsidian.note.Block|?, obsidian.note.HeaderAnchor|?
    local line, block_match, anchor_match
    if block_link ~= nil then
      block_match = note:resolve_block(block_link)
      if block_match then
        line = block_match.line
      end
    elseif anchor_link ~= nil then
      anchor_match = note:resolve_anchor_link(anchor_link)
      if anchor_match then
        line = anchor_match.line
      end
    end

    return vim.tbl_extend(
      "force",
      res,
      { path = note.path, note = note, line = line, block = block_match, anchor = anchor_match }
    )
  end

  ---@type obsidian.note.LoadOpts
  local load_opts = {
    collect_anchor_links = anchor_link and true or false,
    collect_blocks = block_link and true or false,
    max_lines = Obsidian.opts.search_max_lines,
  }

  -- Assume 'location' is current buffer path if empty, like for TOCs.
  if string.len(location) == 0 then
    res.location = vim.api.nvim_buf_get_name(0)
    local note = Note.from_buffer(0, load_opts)
    return callback { finalize_result(note) }
  end

  res.location = location

  M.resolve_note_async(location, function(notes)
    if #notes == 0 then
      local path = Path.new(location)
      if path:exists() then
        res.path = path
        return callback { res }
      else
        return callback { res }
      end
    end

    local matches = {}
    for _, note in ipairs(notes) do
      table.insert(matches, finalize_result(note))
    end

    return callback(matches)
  end, { notes = load_opts, pick = false })
end

---@class obsidian.LinkMatch
---@field link string
---@field line integer
---@field start integer 0-indexed
---@field end integer 0-indexed

-- Gather all unique links from the a note.
--
---@param note obsidian.Note
---@param opts { on_match: fun(link: obsidian.LinkMatch) }
---@param callback fun(links: obsidian.LinkMatch[])
M.find_links = function(note, opts, callback)
  ---@type obsidian.LinkMatch[]
  local matches = {}
  ---@type table<string, boolean>
  local found = {}
  local lines = io.lines(tostring(note.path))

  for lnum, line in util.enumerate(lines) do
    for ref_match in vim.iter(M.find_refs(line, { include_naked_urls = true, include_file_urls = true })) do
      local m_start, m_end = unpack(ref_match)
      local link = string.sub(line, m_start, m_end)
      if not found[link] then
        local match = {
          link = link,
          line = lnum,
          start = m_start - 1,
          ["end"] = m_end - 1,
        }
        matches[#matches + 1] = match
        found[link] = true
        if opts.on_match then
          opts.on_match(match)
        end
      end
    end
  end

  callback(matches)
end

return M

local util = require "obsidian.util"
local log = require "obsidian.log"
local RefTypes = require("obsidian.search").RefTypes
local api = require "obsidian.api"
local search = require "obsidian.search"

---@param client obsidian.Client
---@param picker obsidian.Picker
---@param note obsidian.Note
---@param opts { anchor: string|?, block: string|? }|?
local function collect_backlinks(client, picker, note, opts)
  opts = opts or {}

  client:find_backlinks_async(note, function(backlinks)
    if vim.tbl_isempty(backlinks) then
      if opts.anchor then
        log.info("No backlinks found for anchor '%s' in note '%s'", opts.anchor, note.id)
      elseif opts.block then
        log.info("No backlinks found for block '%s' in note '%s'", opts.block, note.id)
      else
        log.info("No backlinks found for note '%s'", note.id)
      end
      return
    end

    local entries = {}
    for _, matches in ipairs(backlinks) do
      for _, match in ipairs(matches.matches) do
        entries[#entries + 1] = {
          value = { path = matches.path, line = match.line },
          filename = tostring(matches.path),
          lnum = match.line,
        }
      end
    end

    ---@type string
    local prompt_title
    if opts.anchor then
      prompt_title = string.format("Backlinks to '%s%s'", note.id, opts.anchor)
    elseif opts.block then
      prompt_title = string.format("Backlinks to '%s#%s'", note.id, util.standardize_block(opts.block))
    else
      prompt_title = string.format("Backlinks to '%s'", note.id)
    end

    vim.schedule(function()
      picker:pick(entries, {
        prompt_title = prompt_title,
        callback = function(value)
          api.open_buffer(value.path, { line = value.line })
        end,
      })
    end)
  end, { search = { sort = true }, anchor = opts.anchor, block = opts.block })
end

---@param client obsidian.Client
return function(client)
  local picker = assert(Obsidian.picker)
  if not picker then
    log.err "No picker configured"
    return
  end

  local cur_link, link_type = api.cursor_link()

  if
    cur_link ~= nil
    and link_type ~= RefTypes.NakedUrl
    and link_type ~= RefTypes.FileUrl
    and link_type ~= RefTypes.BlockID
  then
    local location = util.parse_link(cur_link, { include_block_ids = true })
    assert(location, "cursor on a link but failed to parse, please report to repo")

    -- Remove block links from the end if there are any.
    -- TODO: handle block links.
    ---@type string|?
    local block_link
    location, block_link = util.strip_block_links(location)

    -- Remove anchor links from the end if there are any.
    ---@type string|?
    local anchor_link
    location, anchor_link = util.strip_anchor_links(location)

    -- Assume 'location' is current buffer path if empty, like for TOCs.
    if string.len(location) == 0 then
      location = vim.api.nvim_buf_get_name(0)
    end

    local opts = { anchor = anchor_link, block = block_link }

    search.resolve_note_async(location, function(...)
      ---@type obsidian.Note[]
      local notes = { ... }

      if #notes == 0 then
        log.err("No notes matching '%s'", location)
        return
      elseif #notes == 1 then
        return collect_backlinks(client, picker, notes[1], opts)
      else
        return vim.schedule(function()
          picker:pick_note(notes, {
            prompt_title = "Select note",
            callback = function(note)
              collect_backlinks(client, picker, note, opts)
            end,
          })
        end)
      end
    end)
  else
    ---@type { anchor: string|?, block: string|? }
    local opts = {}
    ---@type obsidian.note.LoadOpts
    local load_opts = {}

    if cur_link and link_type == RefTypes.BlockID then
      opts.block = util.parse_link(cur_link, { include_block_ids = true })
    else
      load_opts.collect_anchor_links = true
    end

    local note = api.current_note(0, load_opts)

    -- Check if cursor is on a header, if so and header parsing is enabled, use that anchor.
    if Obsidian.opts.backlinks.parse_headers then
      local header_match = util.parse_header(vim.api.nvim_get_current_line())
      if header_match then
        opts.anchor = header_match.anchor
      end
    end

    if note == nil then
      log.err "Current buffer does not appear to be a note inside the vault"
    else
      collect_backlinks(client, picker, note, opts)
    end
  end
end

local log = require "obsidian.log"
local util = require "obsidian.util"
local Note = require "obsidian.note"

---@param data CommandArgs
return function(_, data)
  local picker = Obsidian.picker
  if not picker then
    log.err "No picker configured"
    return
  end

  ---@type string?
  local title = table.concat(data.fargs, " ", 1, #data.fargs - 1)
  local template = data.fargs[#data.fargs]

  if title ~= nil and template ~= nil then
    local note = Note.create { title = title, template = template, should_write = true }
    note:open { sync = true }
    return
  end

  picker:find_templates {
    callback = function(template_name)
      if title == nil or title == "" then
        -- Must use pcall in case of KeyboardInterrupt
        -- We cannot place `title` where `safe_title` is because it would be redeclaring it
        local success, safe_title = pcall(util.input, "Enter title or path (optional): ", { completion = "file" })
        title = safe_title
        if not success or not safe_title then
          log.warn "Aborted"
          return
        elseif safe_title == "" then
          title = nil
        end
      end

      if template_name == nil or template_name == "" then
        log.warn "Aborted"
        return
      end

      ---@type obsidian.Note
      local note = Note.create { title = title, template = template_name, should_write = true }
      note:open { sync = false }
    end,
  }
end

local log = require "obsidian.log"
local util = require "obsidian.util"
local api = require "obsidian.api"

---@param client obsidian.Client
---@param picker obsidian.Picker
---@param tags string[]
local function gather_tag_picker_list(client, picker, tags)
  client:find_tags_async(tags, function(tag_locations)
    -- Format results into picker entries, filtering out results that aren't exact matches or sub-tags.
    ---@type obsidian.PickerEntry[]
    local entries = {}
    for _, tag_loc in ipairs(tag_locations) do
      for _, tag in ipairs(tags) do
        if tag_loc.tag:lower() == tag:lower() or vim.startswith(tag_loc.tag:lower(), tag:lower() .. "/") then
          local display = string.format("%s [%s] %s", tag_loc.note:display_name(), tag_loc.line, tag_loc.text)
          entries[#entries + 1] = {
            value = { path = tag_loc.path, line = tag_loc.line, col = tag_loc.tag_start },
            display = display,
            ordinal = display,
            filename = tostring(tag_loc.path),
            lnum = tag_loc.line,
            col = tag_loc.tag_start,
          }
          break
        end
      end
    end

    if vim.tbl_isempty(entries) then
      if #tags == 1 then
        log.warn "Tag not found"
      else
        log.warn "Tags not found"
      end
      return
    end

    vim.schedule(function()
      picker:pick(entries, {
        prompt_title = "#" .. table.concat(tags, ", #"),
        callback = function(value)
          api.open_buffer(value.path, { line = value.line, col = value.col })
        end,
      })
    end)
  end, { search = { sort = true } })
end

---@param client obsidian.Client
---@param data CommandArgs
return function(client, data)
  local picker = Obsidian.picker
  if not picker then
    log.err "No picker configured"
    return
  end

  local tags = data.fargs or {}

  if vim.tbl_isempty(tags) then
    local tag = api.cursor_tag()
    if tag then
      tags = { tag }
    end
  end

  if not vim.tbl_isempty(tags) then
    return gather_tag_picker_list(client, picker, util.tbl_unique(tags))
  else
    client:list_tags_async(nil, function(all_tags)
      vim.schedule(function()
        -- Open picker with tags.
        picker:pick_tag(all_tags, {
          callback = function(...)
            gather_tag_picker_list(client, picker, { ... })
          end,
          allow_multiple = true,
        })
      end)
    end)
  end
end

local util = require "obsidian.util"
local search = require "obsidian.search"
local iter = vim.iter

local command_lookups = {
  ObsidianCheck = "obsidian.commands.check",
  ObsidianToggleCheckbox = "obsidian.commands.toggle_checkbox",
  ObsidianToday = "obsidian.commands.today",
  ObsidianYesterday = "obsidian.commands.yesterday",
  ObsidianTomorrow = "obsidian.commands.tomorrow",
  ObsidianDailies = "obsidian.commands.dailies",
  ObsidianNew = "obsidian.commands.new",
  ObsidianOpen = "obsidian.commands.open",
  ObsidianBacklinks = "obsidian.commands.backlinks",
  ObsidianSearch = "obsidian.commands.search",
  ObsidianTags = "obsidian.commands.tags",
  ObsidianTemplate = "obsidian.commands.template",
  ObsidianNewFromTemplate = "obsidian.commands.new_from_template",
  ObsidianQuickSwitch = "obsidian.commands.quick_switch",
  ObsidianLinkNew = "obsidian.commands.link_new",
  ObsidianLink = "obsidian.commands.link",
  ObsidianLinks = "obsidian.commands.links",
  ObsidianFollowLink = "obsidian.commands.follow_link",
  ObsidianWorkspace = "obsidian.commands.workspace",
  ObsidianRename = "obsidian.commands.rename",
  ObsidianPasteImg = "obsidian.commands.paste_img",
  ObsidianExtractNote = "obsidian.commands.extract_note",
  ObsidianTOC = "obsidian.commands.toc",
}

local M = setmetatable({
  commands = {},
}, {
  __index = function(t, k)
    local require_path = command_lookups[k]
    if not require_path then
      return
    end

    local mod = require(require_path)
    t[k] = mod

    return mod
  end,
})

---@class obsidian.CommandConfigLegacy
---@field opts table
---@field complete function|?
---@field func function|? (obsidian.Client, table) -> nil

---Register a new command.
---@param name string
---@param config obsidian.CommandConfigLegacy
M.register = function(name, config)
  if not config.func then
    config.func = function(client, data)
      return M[name](client, data)
    end
  end
  M.commands[name] = config
end

---Install all commands.
---
---@param client obsidian.Client
M.install = function(client)
  for command_name, command_config in pairs(M.commands) do
    local func = function(data)
      command_config.func(client, data)
    end

    if command_config.complete ~= nil then
      command_config.opts.complete = function(arg_lead, cmd_line, cursor_pos)
        return command_config.complete(client, arg_lead, cmd_line, cursor_pos)
      end
    end

    vim.api.nvim_create_user_command(command_name, func, command_config.opts)
  end
end

---@param client obsidian.Client
---@return string[]
M.complete_args_search = function(client, _, cmd_line, _)
  local query
  local cmd_arg, _ = util.lstrip_whitespace(string.gsub(cmd_line, "^.*Obsidian[A-Za-z0-9]+", ""))
  if string.len(cmd_arg) > 0 then
    if string.find(cmd_arg, "|", 1, true) then
      return {}
    else
      query = cmd_arg
    end
  else
    local _, csrow, cscol, _ = unpack(assert(vim.fn.getpos "'<"))
    local _, cerow, cecol, _ = unpack(assert(vim.fn.getpos "'>"))
    local lines = vim.fn.getline(csrow, cerow)
    assert(type(lines) == "table")

    if #lines > 1 then
      lines[1] = string.sub(lines[1], cscol)
      lines[#lines] = string.sub(lines[#lines], 1, cecol)
    elseif #lines == 1 then
      lines[1] = string.sub(lines[1], cscol, cecol)
    else
      return {}
    end

    query = table.concat(lines, " ")
  end

  local completions = {}
  local query_lower = string.lower(query)
  for note in iter(search.find_notes(query, { search = { sort = true } })) do
    local note_path = assert(note.path:vault_relative_path { strict = true })
    if string.find(string.lower(note:display_name()), query_lower, 1, true) then
      table.insert(completions, note:display_name() .. "  " .. tostring(note_path))
    else
      for _, alias in pairs(note.aliases) do
        if string.find(string.lower(alias), query_lower, 1, true) then
          table.insert(completions, alias .. "  " .. tostring(note_path))
          break
        end
      end
    end
  end

  return completions
end

M.register("ObsidianCheck", { opts = { nargs = 0, desc = "Check for issues in your vault" } })

M.register("ObsidianToday", { opts = { nargs = "?", desc = "Open today's daily note" } })

M.register("ObsidianYesterday", { opts = { nargs = 0, desc = "Open the daily note for the previous working day" } })

M.register("ObsidianTomorrow", { opts = { nargs = 0, desc = "Open the daily note for the next working day" } })

M.register("ObsidianDailies", { opts = { nargs = "*", desc = "Open a picker with daily notes" } })

M.register("ObsidianNew", { opts = { nargs = "?", desc = "Create a new note" } })

M.register(
  "ObsidianOpen",
  { opts = { nargs = "?", desc = "Open in the Obsidian app" }, complete = M.complete_args_search }
)

M.register("ObsidianBacklinks", { opts = { nargs = 0, desc = "Collect backlinks" } })

M.register("ObsidianTags", { opts = { nargs = "*", range = true, desc = "Find tags" } })

M.register("ObsidianSearch", { opts = { nargs = "?", desc = "Search vault" } })

M.register("ObsidianTemplate", { opts = { nargs = "?", desc = "Insert a template" } })

M.register("ObsidianNewFromTemplate", { opts = { nargs = "*", desc = "Create a new note from a template" } })

M.register("ObsidianQuickSwitch", { opts = { nargs = "?", desc = "Switch notes" } })

M.register("ObsidianLinkNew", { opts = { nargs = "?", range = true, desc = "Link selected text to a new note" } })

M.register("ObsidianLink", {
  opts = { nargs = "?", range = true, desc = "Link selected text to an existing note" },
  complete = M.complete_args_search,
})

M.register("ObsidianLinks", { opts = { nargs = 0, desc = "Collect all links within the current buffer" } })

M.register("ObsidianFollowLink", { opts = { nargs = "?", desc = "Follow reference or link under cursor" } })

M.register("ObsidianToggleCheckbox", { opts = { nargs = 0, desc = "Toggle checkbox", range = true } })

M.register("ObsidianWorkspace", { opts = { nargs = "?", desc = "Check or switch workspace" } })

M.register("ObsidianRename", { opts = { nargs = "?", desc = "Rename note and update all references to it" } })

M.register("ObsidianPasteImg", { opts = { nargs = "?", desc = "Paste an image from the clipboard" } })

M.register(
  "ObsidianExtractNote",
  { opts = { nargs = "?", range = true, desc = "Extract selected text to a new note and link to it" } }
)

M.register("ObsidianTOC", { opts = { nargs = 0, desc = "Load the table of contents into a picker" } })

return M

local mini_pick = require "mini.pick"

local Path = require "obsidian.path"
local abc = require "obsidian.abc"
local Picker = require "obsidian.pickers.picker"

---@param entry string
---@return string
local function clean_path(entry)
  local path_end = assert(string.find(entry, ":", 1, true))
  return string.sub(entry, 1, path_end - 1)
end

---@class obsidian.pickers.MiniPicker : obsidian.Picker
local MiniPicker = abc.new_class({
  ---@diagnostic disable-next-line: unused-local
  __tostring = function(self)
    return "MiniPicker()"
  end,
}, Picker)

---@param opts obsidian.PickerFindOpts|? Options.
MiniPicker.find_files = function(self, opts)
  opts = opts or {}

  ---@type obsidian.Path
  local dir = opts.dir and Path:new(opts.dir) or Obsidian.dir

  local path = mini_pick.builtin.cli({
    command = self:_build_find_cmd(),
  }, {
    source = {
      name = opts.prompt_title,
      cwd = tostring(dir),
      choose = function(path)
        if not opts.no_default_mappings then
          mini_pick.default_choose(path)
        end
      end,
    },
  })

  if path and opts.callback then
    opts.callback(tostring(dir / path))
  end
end

---@param opts obsidian.PickerGrepOpts|? Options.
MiniPicker.grep = function(self, opts)
  opts = opts and opts or {}

  ---@type obsidian.Path
  local dir = opts.dir and Path:new(opts.dir) or Obsidian.dir

  local pick_opts = {
    source = {
      name = opts.prompt_title,
      cwd = tostring(dir),
      choose = function(path)
        if not opts.no_default_mappings then
          mini_pick.default_choose(path)
        end
      end,
    },
  }

  ---@type string|?
  local result
  if opts.query and string.len(opts.query) > 0 then
    result = mini_pick.builtin.grep({ pattern = opts.query }, pick_opts)
  else
    result = mini_pick.builtin.grep_live({}, pick_opts)
  end

  if result and opts.callback then
    local path = clean_path(result)
    opts.callback(tostring(dir / path))
  end
end

---@param values string[]|obsidian.PickerEntry[]
---@param opts obsidian.PickerPickOpts|? Options.
---@diagnostic disable-next-line: unused-local
MiniPicker.pick = function(self, values, opts)
  self.calling_bufnr = vim.api.nvim_get_current_buf()

  opts = opts and opts or {}

  local entries = {}
  for _, value in ipairs(values) do
    if type(value) == "string" then
      entries[#entries + 1] = value
    elseif value.valid ~= false then
      entries[#entries + 1] = {
        value = value.value,
        text = self:_make_display(value),
        path = value.filename,
        lnum = value.lnum,
        col = value.col,
      }
    end
  end

  local entry = mini_pick.start {
    source = {
      name = opts.prompt_title,
      items = entries,
      choose = function() end,
    },
  }

  if entry and opts.callback then
    if type(entry) == "string" then
      opts.callback(entry)
    else
      opts.callback(entry.value)
    end
  end
end

return MiniPicker

local Path = require "obsidian.path"
local abc = require "obsidian.abc"
local api = require "obsidian.api"
local util = require "obsidian.util"
local config = require "obsidian.config"
local log = require "obsidian.log"

---@class obsidian.workspace.WorkspaceSpec
---
---@field path string|(fun(): string)
---@field name string|?
---@field strict boolean|? If true, the workspace root will be fixed to 'path' instead of the vault root (if different).
---@field overrides table|obsidian.config.ClientOpts?

---@class obsidian.workspace.WorkspaceOpts
---
---@field name string|?
---@field strict boolean|? If true, the workspace root will be fixed to 'path' instead of the vault root (if different).
---@field overrides table|obsidian.config.ClientOpts|?

--- Each workspace represents a working directory (usually an Obsidian vault) along with
--- a set of configuration options specific to the workspace.
---
--- Workspaces are a little more general than Obsidian vaults as you can have a workspace
--- outside of a vault or as a subdirectory of a vault.
---
---@toc_entry obsidian.Workspace
---
---@class obsidian.Workspace : obsidian.ABC
---
---@field name string An arbitrary name for the workspace.
---@field path obsidian.Path The normalized path to the workspace.
---@field root obsidian.Path The normalized path to the vault root of the workspace. This usually matches 'path'.
---@field overrides table|obsidian.config.ClientOpts|?
---@field locked boolean|?
local Workspace = abc.new_class {
  __tostring = function(self)
    return string.format("Workspace(name='%s', path='%s', root='%s')", self.name, self.path, self.root)
  end,
  __eq = function(a, b)
    local a_fields = a:as_tbl()
    a_fields.locked = nil
    local b_fields = b:as_tbl()
    b_fields.locked = nil
    return vim.deep_equal(a_fields, b_fields)
  end,
}

--- Find the vault root from a given directory.
---
--- This will traverse the directory tree upwards until a '.obsidian/' folder is found to
--- indicate the root of a vault, otherwise the given directory is used as-is.
---
---@param base_dir string|obsidian.Path
---
---@return obsidian.Path|?
local function find_vault_root(base_dir)
  local vault_indicator_folder = ".obsidian"
  base_dir = Path.new(base_dir)
  local dirs = Path.new(base_dir):parents()
  table.insert(dirs, 1, base_dir)

  for _, dir in ipairs(dirs) do
    local maybe_vault = dir / vault_indicator_folder
    if maybe_vault:is_dir() then
      return dir
    end
  end

  return nil
end

--- Create a new 'Workspace' object. This assumes the workspace already exists on the filesystem.
---
---@param path string|obsidian.Path Workspace path.
---@param opts obsidian.workspace.WorkspaceOpts|?
---
---@return obsidian.Workspace
Workspace.new = function(path, opts)
  opts = opts and opts or {}

  local self = Workspace.init()
  self.path = Path.new(path):resolve { strict = true }
  self.name = assert(opts.name or self.path.name)
  self.overrides = opts.overrides

  if opts.strict then
    self.root = self.path
  else
    local vault_root = find_vault_root(self.path)
    if vault_root then
      self.root = vault_root
    else
      self.root = self.path
    end
  end

  return self
end

--- Initialize a new 'Workspace' object from a workspace spec.
---
---@param spec obsidian.workspace.WorkspaceSpec
---
---@return obsidian.Workspace
Workspace.new_from_spec = function(spec)
  ---@type string|obsidian.Path
  local path
  if type(spec.path) == "function" then
    path = spec.path()
  else
    ---@diagnostic disable-next-line: cast-local-type
    path = spec.path
  end

  ---@diagnostic disable-next-line: param-type-mismatch
  return Workspace.new(path, {
    name = spec.name,
    strict = spec.strict,
    overrides = spec.overrides,
  })
end

--- Lock the workspace.
Workspace.lock = function(self)
  self.locked = true
end

--- Unlock the workspace.
Workspace._unlock = function(self)
  self.locked = false
end

--- Get the workspace corresponding to the directory (or a parent of), if there
--- is one.
---
---@param cur_dir string|obsidian.Path
---@param workspaces obsidian.workspace.WorkspaceSpec[]
---
---@return obsidian.Workspace|?
Workspace.get_workspace_for_dir = function(cur_dir, workspaces)
  local ok
  ok, cur_dir = pcall(function()
    return Path.new(cur_dir):resolve { strict = true }
  end)

  if not ok then
    return
  end

  for _, spec in ipairs(workspaces) do
    local w = Workspace.new_from_spec(spec)
    if w.path == cur_dir or w.path:is_parent_of(cur_dir) then
      return w
    end
  end
end

--- Get the workspace corresponding to the current working directory (or a parent of), if there
--- is one.
---
---@param workspaces obsidian.workspace.WorkspaceSpec[]
---
---@return obsidian.Workspace|?
Workspace.get_workspace_for_cwd = function(workspaces)
  local cwd = assert(vim.fn.getcwd())
  return Workspace.get_workspace_for_dir(cwd, workspaces)
end

--- Returns the default workspace.
---
---@param workspaces obsidian.workspace.WorkspaceSpec[]
---
---@return obsidian.Workspace|nil
Workspace.get_default_workspace = function(workspaces)
  if not vim.tbl_isempty(workspaces) then
    return Workspace.new_from_spec(workspaces[1])
  else
    return nil
  end
end

--- Resolves current workspace from the client config.
---
---@param opts obsidian.config.ClientOpts
---
---@return obsidian.Workspace|?
Workspace.get_from_opts = function(opts)
  local current_workspace = Workspace.get_workspace_for_cwd(opts.workspaces)

  if not current_workspace then
    current_workspace = Workspace.get_default_workspace(opts.workspaces)
  end

  return current_workspace
end

--- Get the normalize opts for a given workspace.
---
---@param workspace obsidian.Workspace|?
---
---@return obsidian.config.ClientOpts
Workspace.normalize_opts = function(workspace)
  if workspace then
    return config.normalize(workspace.overrides and workspace.overrides or {}, Obsidian._opts)
  else
    return Obsidian.opts
  end
end

---@param workspace obsidian.Workspace
---@param opts { lock: boolean|? }|?
Workspace.set = function(workspace, opts)
  opts = opts and opts or {}

  local dir = workspace.root
  local options = Workspace.normalize_opts(workspace) -- TODO: test

  Obsidian.workspace = workspace
  Obsidian.dir = dir
  Obsidian.opts = options

  -- Ensure directories exist.
  dir:mkdir { parents = true, exists_ok = true }

  if options.notes_subdir ~= nil then
    local notes_subdir = dir / Obsidian.opts.notes_subdir
    notes_subdir:mkdir { parents = true, exists_ok = true }
  end

  if Obsidian.opts.daily_notes.folder ~= nil then
    local daily_notes_subdir = Obsidian.dir / Obsidian.opts.daily_notes.folder
    daily_notes_subdir:mkdir { parents = true, exists_ok = true }
  end

  -- Setup UI add-ons.
  local has_no_renderer = not (api.get_plugin_info "render-markdown.nvim" or api.get_plugin_info "markview.nvim")
  if has_no_renderer and Obsidian.opts.ui.enable then
    require("obsidian.ui").setup(Obsidian.workspace, Obsidian.opts.ui)
  end

  if opts.lock then
    Obsidian.workspace:lock()
  end

  Obsidian.picker = require("obsidian.pickers").get(Obsidian.opts.picker.name)

  util.fire_callback("post_set_workspace", Obsidian.opts.callbacks.post_set_workspace, workspace)

  vim.api.nvim_exec_autocmds("User", {
    pattern = "ObsidianWorkpspaceSet",
    data = { workspace = workspace },
  })
end

---@param workspace obsidian.Workspace|string The workspace object or the name of an existing workspace.
---@param opts { lock: boolean|? }|?
Workspace.switch = function(workspace, opts)
  opts = opts and opts or {}

  if workspace == Obsidian.workspace.name then
    log.info("Already in workspace '%s' @ '%s'", workspace, Obsidian.workspace.path)
    return
  end

  for _, ws in ipairs(Obsidian.opts.workspaces) do
    if ws.name == workspace then
      return Workspace.set(Workspace.new_from_spec(ws), opts)
    end
  end

  error(string.format("Workspace '%s' not found", workspace))
end

return Workspace

local templates = require "obsidian.templates"
local log = require "obsidian.log"
local api = require "obsidian.api"

---@param data CommandArgs
return function(_, data)
  local templates_dir = api.templates_dir()
  if not templates_dir then
    log.err "Templates folder is not defined or does not exist"
    return
  end

  -- We need to get this upfront before the picker hijacks the current window.
  local insert_location = api.get_active_window_cursor_location()

  local function insert_template(name)
    templates.insert_template {
      type = "insert_template",
      template_name = name,
      template_opts = Obsidian.opts.templates,
      templates_dir = templates_dir,
      location = insert_location,
    }
  end

  if string.len(data.args) > 0 then
    local template_name = vim.trim(data.args)
    insert_template(template_name)
    return
  end

  local picker = Obsidian.picker
  if not picker then
    log.err "No picker configured"
    return
  end

  picker:find_templates {
    callback = function(path)
      insert_template(path)
    end,
  }
end

local iter = vim.iter
local log = require "obsidian.log"
local legacycommands = require "obsidian.commands.init-legacy"
local search = require "obsidian.search"

local M = { commands = {} }

local function in_note()
  return vim.bo.filetype == "markdown"
end

---@param commands obsidian.CommandConfig[]
---@param is_visual boolean
---@param is_note boolean
---@return string[]
local function get_commands_by_context(commands, is_visual, is_note)
  local choices = vim.tbl_values(commands)
  return vim
    .iter(choices)
    :filter(function(config)
      if is_visual then
        return config.range ~= nil
      else
        return config.range == nil
      end
    end)
    :filter(function(config)
      if is_note then
        return true
      else
        return not config.note_action
      end
    end)
    :map(function(config)
      return config.name
    end)
    :totable()
end

local function show_menu(data)
  local is_visual, is_note = data.range ~= 0, in_note()
  local choices = get_commands_by_context(M.commands, is_visual, is_note)

  vim.ui.select(choices, { prompt = "Obsidian Commands" }, function(item)
    if item then
      return vim.cmd.Obsidian(item)
    else
      vim.notify("Aborted", 3)
    end
  end)
end

---@class obsidian.CommandConfig
---@field complete function|string|?
---@field nargs string|integer|?
---@field range boolean|?
---@field func function|? (obsidian.Client, table) -> nil
---@field name string?
---@field note_action boolean?

---Register a new command.
---@param name string
---@param config obsidian.CommandConfig
M.register = function(name, config)
  if not config.func then
    config.func = function(client, data)
      local mod = require("obsidian.commands." .. name)
      return mod(client, data)
    end
  end
  config.name = name
  M.commands[name] = config
end

---Install all commands.
---
---@param client obsidian.Client
M.install = function(client)
  vim.api.nvim_create_user_command("Obsidian", function(data)
    if #data.fargs == 0 then
      show_menu(data)
      return
    end
    M.handle_command(client, data)
  end, {
    nargs = "*",
    complete = function(_, cmdline, _)
      return M.get_completions(client, cmdline)
    end,
    range = 2,
  })
end

M.install_legacy = legacycommands.install

---@param client obsidian.Client
M.handle_command = function(client, data)
  local cmd = data.fargs[1]
  table.remove(data.fargs, 1)
  data.args = table.concat(data.fargs, " ")
  local nargs = #data.fargs

  local cmdconfig = M.commands[cmd]
  if cmdconfig == nil then
    log.err("Command '" .. cmd .. "' not found")
    return
  end

  local exp_nargs = cmdconfig.nargs
  local range_allowed = cmdconfig.range

  if exp_nargs == "?" then
    if nargs > 1 then
      log.err("Command '" .. cmd .. "' expects 0 or 1 arguments, but " .. nargs .. " were provided")
      return
    end
  elseif exp_nargs == "+" then
    if nargs == 0 then
      log.err("Command '" .. cmd .. "' expects at least one argument, but none were provided")
      return
    end
  elseif exp_nargs ~= "*" and exp_nargs ~= nargs then
    log.err("Command '" .. cmd .. "' expects " .. exp_nargs .. " arguments, but " .. nargs .. " were provided")
    return
  end

  if not range_allowed and data.range > 0 then
    log.error("Command '" .. cmd .. "' does not accept a range")
    return
  end

  cmdconfig.func(client, data)
end

---@param client obsidian.Client
---@param cmdline string
M.get_completions = function(client, cmdline)
  local obspat = "^['<,'>]*Obsidian[!]?"
  local splitcmd = vim.split(cmdline, " ", { plain = true, trimempty = true })
  local obsidiancmd = splitcmd[2]
  if cmdline:match(obspat .. "%s$") then
    local is_visual = vim.startswith(cmdline, "'<,'>")
    return get_commands_by_context(M.commands, is_visual, in_note())
  end
  if cmdline:match(obspat .. "%s%S+$") then
    return vim.tbl_filter(function(s)
      return s:sub(1, #obsidiancmd) == obsidiancmd
    end, vim.tbl_keys(M.commands))
  end
  local cmdconfig = M.commands[obsidiancmd]
  if cmdconfig == nil then
    return
  end
  if cmdline:match(obspat .. "%s%S*%s%S*$") then
    local cmd_arg = table.concat(vim.list_slice(splitcmd, 3), " ")
    local complete_type = type(cmdconfig.complete)
    if complete_type == "function" then
      return cmdconfig.complete(cmd_arg)
    end
    if complete_type == "string" then
      return vim.fn.getcompletion(cmd_arg, cmdconfig.complete)
    end
  end
end

--TODO: Note completion is currently broken (see: https://github.com/epwalsh/obsidian.nvim/issues/753)
---@return string[]
M.note_complete = function(cmd_arg)
  local query
  if string.len(cmd_arg) > 0 then
    if string.find(cmd_arg, "|", 1, true) then
      return {}
    else
      query = cmd_arg
    end
  else
    local _, csrow, cscol, _ = unpack(assert(vim.fn.getpos "'<"))
    local _, cerow, cecol, _ = unpack(assert(vim.fn.getpos "'>"))
    local lines = vim.fn.getline(csrow, cerow)
    assert(type(lines) == "table")

    if #lines > 1 then
      lines[1] = string.sub(lines[1], cscol)
      lines[#lines] = string.sub(lines[#lines], 1, cecol)
    elseif #lines == 1 then
      lines[1] = string.sub(lines[1], cscol, cecol)
    else
      return {}
    end

    query = table.concat(lines, " ")
  end

  local completions = {}
  local query_lower = string.lower(query)
  for note in iter(search.find_notes(query, { search = { sort = true } })) do
    local note_path = assert(note.path:vault_relative_path { strict = true })
    if string.find(string.lower(note:display_name()), query_lower, 1, true) then
      table.insert(completions, note:display_name() .. "  " .. tostring(note_path))
    else
      for _, alias in pairs(note.aliases) do
        if string.find(string.lower(alias), query_lower, 1, true) then
          table.insert(completions, alias .. "  " .. tostring(note_path))
          break
        end
      end
    end
  end

  return completions
end

------------------------
---- general action ----
------------------------

M.register("check", { nargs = 0 })

M.register("today", { nargs = "?" })

M.register("yesterday", { nargs = 0 })

M.register("tomorrow", { nargs = 0 })

M.register("dailies", { nargs = "*" })

M.register("new", { nargs = "?" })

M.register("open", { nargs = "?", complete = M.note_complete })

M.register("tags", { nargs = "*" })

M.register("search", { nargs = "?" })

M.register("new_from_template", { nargs = "*" })

M.register("quick_switch", { nargs = "?" })

M.register("workspace", { nargs = "?" })

---------------------
---- note action ----
---------------------

M.register("backlinks", { nargs = 0, note_action = true })

M.register("template", { nargs = "?", note_action = true })

M.register("link_new", { mode = "v", nargs = "?", range = true, note_action = true })

M.register("link", { nargs = "?", range = true, complete = M.note_complete, note_action = true })

M.register("links", { nargs = 0, note_action = true })

M.register("follow_link", { nargs = "?", note_action = true })

M.register("toggle_checkbox", { nargs = 0, range = true, note_action = true })

M.register("rename", { nargs = "?", note_action = true })

M.register("paste_img", { nargs = "?", note_action = true })

M.register("extract_note", { mode = "v", nargs = "?", range = true, note_action = true })

M.register("toc", { nargs = 0, note_action = true })

return M

local log = require "obsidian.log"

local module_lookups = {
  abc = "obsidian.abc",
  api = "obsidian.api",
  async = "obsidian.async",
  Client = "obsidian.client",
  commands = "obsidian.commands",
  completion = "obsidian.completion",
  config = "obsidian.config",
  log = "obsidian.log",
  img_paste = "obsidian.img_paste",
  Note = "obsidian.note",
  Path = "obsidian.path",
  pickers = "obsidian.pickers",
  search = "obsidian.search",
  templates = "obsidian.templates",
  ui = "obsidian.ui",
  util = "obsidian.util",
  VERSION = "obsidian.version",
  Workspace = "obsidian.workspace",
  yaml = "obsidian.yaml",
}

local obsidian = setmetatable({}, {
  __index = function(t, k)
    local require_path = module_lookups[k]
    if not require_path then
      return
    end

    local mod = require(require_path)
    t[k] = mod

    return mod
  end,
})

---@type obsidian.Client|?
obsidian._client = nil

---Get the current obsidian client.
---@return obsidian.Client
obsidian.get_client = function()
  if obsidian._client == nil then
    error "Obsidian client has not been set! Did you forget to call 'setup()'?"
  else
    return obsidian._client
  end
end

obsidian.register_command = require("obsidian.commands").register

--- Setup a new Obsidian client. This should only be called once from an Nvim session.
---
---@param opts obsidian.config.ClientOpts | table<string, any>
---
---@return obsidian.Client
obsidian.setup = function(opts)
  opts = obsidian.config.normalize(opts)

  ---@class obsidian.state
  ---@field picker obsidian.Picker The picker instance to use.
  ---@field workspace obsidian.Workspace The current workspace.
  ---@field dir obsidian.Path The root of the vault for the current workspace.
  ---@field buf_dir obsidian.Path|? The parent directory of the current buffer.
  ---@field opts obsidian.config.ClientOpts current options
  ---@field _opts obsidian.config.ClientOpts default options
  _G.Obsidian = {} -- init a state table

  local client = obsidian.Client.new(opts)

  Obsidian._opts = opts

  obsidian.Workspace.set(assert(obsidian.Workspace.get_from_opts(opts)), {})

  log.set_level(Obsidian.opts.log_level)

  -- Install commands.
  -- These will be available across all buffers, not just note buffers in the vault.
  obsidian.commands.install(client)

  if opts.legacy_commands then
    obsidian.commands.install_legacy(client)
  end

  if opts.statusline.enabled then
    require("obsidian.statusline").start(client)
  end

  if opts.footer.enabled then
    require("obsidian.footer").start(client)
  end

  -- Register completion sources, providers
  if opts.completion.nvim_cmp then
    require("obsidian.completion.plugin_initializers.nvim_cmp").register_sources(opts)
  elseif opts.completion.blink then
    require("obsidian.completion.plugin_initializers.blink").register_providers(opts)
  end

  local group = vim.api.nvim_create_augroup("obsidian_setup", { clear = true })

  -- wrapper for creating autocmd events
  ---@param pattern string
  ---@param buf integer
  local function exec_autocmds(pattern, buf)
    vim.api.nvim_exec_autocmds("User", {
      pattern = pattern,
      data = {
        note = require("obsidian.note").from_buffer(buf),
      },
    })
  end

  -- Complete setup and update workspace (if needed) when entering a markdown buffer.
  vim.api.nvim_create_autocmd({ "BufEnter" }, {
    group = group,
    pattern = "*.md",
    callback = function(ev)
      -- Set the current directory of the buffer.
      local buf_dir = vim.fs.dirname(ev.match)
      if buf_dir then
        Obsidian.buf_dir = obsidian.Path.new(buf_dir)
      end

      -- Check if we're in *any* workspace.
      local workspace = obsidian.Workspace.get_workspace_for_dir(buf_dir, Obsidian.opts.workspaces)
      if not workspace then
        return
      end

      if opts.comment.enabled then
        vim.o.commentstring = "%%%s%%"
      end

      -- Switch to the workspace and complete the workspace setup.
      if not Obsidian.workspace.locked and workspace ~= Obsidian.workspace then
        log.debug("Switching to workspace '%s' @ '%s'", workspace.name, workspace.path)
        obsidian.Workspace.set(workspace)
        require("obsidian.ui").update(ev.buf)
      end

      -- Register keymap.
      vim.keymap.set(
        "n",
        "<CR>",
        obsidian.api.smart_action,
        { expr = true, buffer = true, desc = "Obsidian Smart Action" }
      )

      vim.keymap.set("n", "]o", function()
        obsidian.api.nav_link "next"
      end, { buffer = true, desc = "Obsidian Next Link" })

      vim.keymap.set("n", "[o", function()
        obsidian.api.nav_link "prev"
      end, { buffer = true, desc = "Obsidian Previous Link" })

      -- Inject completion sources, providers to their plugin configurations
      if opts.completion.nvim_cmp then
        require("obsidian.completion.plugin_initializers.nvim_cmp").inject_sources(opts)
      elseif opts.completion.blink then
        require("obsidian.completion.plugin_initializers.blink").inject_sources(opts)
      end

      -- Run enter-note callback.
      local note = obsidian.Note.from_buffer(ev.buf)
      obsidian.util.fire_callback("enter_note", Obsidian.opts.callbacks.enter_note, client, note)

      exec_autocmds("ObsidianNoteEnter", ev.buf)
    end,
  })

  vim.api.nvim_create_autocmd({ "BufLeave" }, {
    group = group,
    pattern = "*.md",
    callback = function(ev)
      -- Check if we're in *any* workspace.
      local workspace = obsidian.Workspace.get_workspace_for_dir(vim.fs.dirname(ev.match), Obsidian.opts.workspaces)
      if not workspace then
        return
      end

      -- Check if current buffer is actually a note within the workspace.
      if not obsidian.api.path_is_note(ev.match) then
        return
      end

      -- Run leave-note callback.
      local note = obsidian.Note.from_buffer(ev.buf)
      obsidian.util.fire_callback("leave_note", Obsidian.opts.callbacks.leave_note, client, note)

      exec_autocmds("ObsidianNoteLeave", ev.buf)
    end,
  })

  -- Add/update frontmatter for notes before writing.
  vim.api.nvim_create_autocmd({ "BufWritePre" }, {
    group = group,
    pattern = "*.md",
    callback = function(ev)
      local buf_dir = vim.fs.dirname(ev.match)

      -- Check if we're in a workspace.
      local workspace = obsidian.Workspace.get_workspace_for_dir(buf_dir, Obsidian.opts.workspaces)
      if not workspace then
        return
      end

      -- Check if current buffer is actually a note within the workspace.
      if not obsidian.api.path_is_note(ev.match) then
        return
      end

      -- Initialize note.
      local bufnr = ev.buf
      local note = obsidian.Note.from_buffer(bufnr)

      -- Run pre-write-note callback.
      obsidian.util.fire_callback("pre_write_note", Obsidian.opts.callbacks.pre_write_note, client, note)

      exec_autocmds("ObsidianNoteWritePre", ev.buf)

      -- Update buffer with new frontmatter.
      if note:update_frontmatter(bufnr) then
        log.info "Updated frontmatter"
      end
    end,
  })

  vim.api.nvim_create_autocmd({ "BufWritePost" }, {
    group = group,
    pattern = "*.md",
    callback = function(ev)
      local buf_dir = vim.fs.dirname(ev.match)

      -- Check if we're in a workspace.
      local workspace = obsidian.Workspace.get_workspace_for_dir(buf_dir, Obsidian.opts.workspaces)
      if not workspace then
        return
      end

      -- Check if current buffer is actually a note within the workspace.
      if not obsidian.api.path_is_note(ev.match) then
        return
      end

      exec_autocmds("ObsidianNoteWritePost", ev.buf)
    end,
  })

  -- Set global client.
  obsidian._client = client

  obsidian.util.fire_callback("post_setup", Obsidian.opts.callbacks.post_setup, client)

  return client
end

return obsidian

local abc = require "obsidian.abc"

local function coerce(v)
  if v == vim.NIL then
    return nil
  else
    return v
  end
end

---@param path table
---@param k string
---@param factory fun(obsidian.Path): any
local function cached_get(path, k, factory)
  local cache_key = "__" .. k
  local v = rawget(path, cache_key)
  if v == nil then
    v = factory(path)
    if v == nil then
      v = vim.NIL
    end
    path[cache_key] = v
  end
  return coerce(v)
end

---@param path obsidian.Path
---@return string|?
---@private
local function get_name(path)
  local name = vim.fs.basename(path.filename)
  if not name or string.len(name) == 0 then
    return
  else
    return name
  end
end

---@param path obsidian.Path
---@return string[]
---@private
local function get_suffixes(path)
  ---@type string[]
  local suffixes = {}
  local name = path.name
  while name and string.len(name) > 0 do
    local s, e, suffix = string.find(name, "(%.[^%.]+)$")
    if s and e and suffix then
      name = string.sub(name, 1, s - 1)
      table.insert(suffixes, suffix)
    else
      break
    end
  end

  -- reverse the list.
  ---@type string[]
  local out = {}
  for i = #suffixes, 1, -1 do
    table.insert(out, suffixes[i])
  end
  return out
end

---@param path obsidian.Path
---@return string|?
---@private
local function get_suffix(path)
  local suffixes = path.suffixes
  if #suffixes > 0 then
    return suffixes[#suffixes]
  else
    return nil
  end
end

---@param path obsidian.Path
---@return string|?
---@private
local function get_stem(path)
  local name, suffix = path.name, path.suffix
  if not name then
    return
  elseif not suffix then
    return name
  else
    return string.sub(name, 1, string.len(name) - string.len(suffix))
  end
end

--- A `Path` class that provides a subset of the functionality of the Python `pathlib` library while
--- staying true to its API. It improves on a number of bugs in `plenary.path`.
---
---@toc_entry obsidian.Path
---
---@class obsidian.Path : obsidian.ABC
---
---@field filename string The underlying filename as a string.
---@field name string|? The final path component, if any.
---@field suffix string|? The final extension of the path, if any.
---@field suffixes string[] A list of all of the path's extensions.
---@field stem string|? The final path component, without its suffix.
local Path = abc.new_class()

Path.mt = {
  __tostring = function(self)
    return self.filename
  end,
  __eq = function(a, b)
    return a.filename == b.filename
  end,
  __div = function(self, other)
    return self:joinpath(other)
  end,
  __index = function(self, k)
    local raw = rawget(Path, k)
    if raw then
      return raw
    end

    local factory
    if k == "name" then
      factory = get_name
    elseif k == "suffix" then
      factory = get_suffix
    elseif k == "suffixes" then
      factory = get_suffixes
    elseif k == "stem" then
      factory = get_stem
    end

    if factory then
      return cached_get(self, k, factory)
    end
  end,
}

--- Check if an object is an `obsidian.Path` object.
---
---@param path any
---
---@return boolean
Path.is_path_obj = function(path)
  if getmetatable(path) == Path.mt then
    return true
  else
    return false
  end
end

-------------------------------------------------------------------------------
--- Constructors.
-------------------------------------------------------------------------------

--- Create a new path from a string.
---
---@param ... string|obsidian.Path
---
---@return obsidian.Path
Path.new = function(...)
  local self = Path.init()

  local args = { ... }
  local arg
  if #args == 1 then
    arg = tostring(args[1])
  elseif #args == 2 and args[1] == Path then
    arg = tostring(args[2])
  else
    error "expected one argument"
  end

  if Path.is_path_obj(arg) then
    ---@cast arg obsidian.Path
    return arg
  end

  self.filename = vim.fs.normalize(tostring(arg))

  return self
end

--- Get a temporary path with a unique name.
---
---@param opts { suffix: string|? }|?
---
---@return obsidian.Path
Path.temp = function(opts)
  opts = opts or {}
  local tmpname = vim.fn.tempname()
  if opts.suffix then
    tmpname = tmpname .. opts.suffix
  end
  return Path.new(tmpname)
end

--- Get a path corresponding to the current working directory as given by `vim.uv.cwd()`.
---
---@return obsidian.Path
Path.cwd = function()
  return assert(Path.new(vim.uv.cwd()))
end

--- Get a path corresponding to a buffer.
---
---@param bufnr integer|? The buffer number or `0` / `nil` for the current buffer.
---
---@return obsidian.Path
Path.buffer = function(bufnr)
  return Path.new(vim.api.nvim_buf_get_name(bufnr or 0))
end

--- Get a path corresponding to the parent of a buffer.
---
---@param bufnr integer|? The buffer number or `0` / `nil` for the current buffer.
---
---@return obsidian.Path
Path.buf_dir = function(bufnr)
  return assert(Path.buffer(bufnr):parent())
end

-------------------------------------------------------------------------------
--- Pure path methods.
-------------------------------------------------------------------------------

--- Return a new path with the suffix changed.
---
---@param suffix string
---@param should_append boolean|? should the suffix append a suffix instead of replacing one which may be there?
---
---@return obsidian.Path
Path.with_suffix = function(self, suffix, should_append)
  if not vim.startswith(suffix, ".") and string.len(suffix) > 1 then
    error(string.format("invalid suffix '%s'", suffix))
  elseif self.stem == nil then
    error(string.format("path '%s' has no stem", self.filename))
  end

  local new_name = ((should_append == true) and self.name or self.stem) .. suffix

  ---@type obsidian.Path|?
  local parent = nil
  if self.name ~= self.filename then
    parent = self:parent()
  end

  if parent then
    return parent / new_name
  else
    return Path.new(new_name)
  end
end

--- Returns true if the path is already in absolute form.
---
---@return boolean
Path.is_absolute = function(self)
  local api = require "obsidian.api"
  if
    vim.startswith(self.filename, "/")
    or (
      (api.get_os() == api.OSType.Windows or api.get_os() == api.OSType.Wsl)
      and string.match(self.filename, "^[%a]:/.*$")
    )
  then
    return true
  else
    return false
  end
end

---@param ... obsidian.Path|string
---@return obsidian.Path
Path.joinpath = function(self, ...)
  local args = vim.iter({ ... }):map(tostring):totable()
  return Path.new(vim.fs.joinpath(self.filename, unpack(args)))
end

--- Try to resolve a version of the path relative to the other.
--- An error is raised when it's not possible.
---
---@param other obsidian.Path|string
---
---@return obsidian.Path
Path.relative_to = function(self, other)
  other = Path.new(other)

  local other_fname = other.filename
  if not vim.endswith(other_fname, "/") then
    other_fname = other_fname .. "/"
  end

  if vim.startswith(self.filename, other_fname) then
    return Path.new(string.sub(self.filename, string.len(other_fname) + 1))
  end

  -- Edge cases when the paths are relative or under-specified, see tests.
  if not self:is_absolute() and not vim.startswith(self.filename, "./") and vim.startswith(other_fname, "./") then
    if other_fname == "./" then
      return self
    end

    local self_rel_to_cwd = Path.new "./" / self
    if vim.startswith(self_rel_to_cwd.filename, other_fname) then
      return Path.new(string.sub(self_rel_to_cwd.filename, string.len(other_fname) + 1))
    end
  end

  error(string.format("'%s' is not in the subpath of '%s'", self.filename, other.filename))
end

--- The logical parent of the path.
---
---@return obsidian.Path|?
Path.parent = function(self)
  local parent = vim.fs.dirname(self.filename)
  if parent ~= nil then
    return Path.new(parent)
  else
    return nil
  end
end

--- Get a list of the parent directories.
---
---@return obsidian.Path[]
Path.parents = function(self)
  return vim.iter(vim.fs.parents(self.filename)):map(Path.new):totable()
end

--- Check if the path is a parent of other. This is a pure path method, so it only checks by
--- comparing strings. Therefore in practice you probably want to `:resolve()` each path before
--- using this.
---
---@param other obsidian.Path|string
---
---@return boolean
Path.is_parent_of = function(self, other)
  other = Path.new(other)
  for _, parent in ipairs(other:parents()) do
    if parent == self then
      return true
    end
  end
  return false
end

---@return string?
---@private
Path.abspath = function(self)
  local path = vim.loop.fs_realpath(vim.fn.resolve(self.filename))
  return path
end

-------------------------------------------------------------------------------
--- Concrete path methods.
-------------------------------------------------------------------------------

--- Make the path absolute, resolving any symlinks.
--- If `strict` is true and the path doesn't exist, an error is raised.
---
---@param opts { strict: boolean }|?
---
---@return obsidian.Path
Path.resolve = function(self, opts)
  opts = opts or {}

  local realpath = self:abspath()
  if realpath then
    return Path.new(realpath)
  elseif opts.strict then
    error("FileNotFoundError: " .. self.filename)
  end

  -- File doesn't exist, but some parents might. Traverse up until we find a parent that
  -- does exist, and then put the path back together from there.
  local parents = self:parents()
  for _, parent in ipairs(parents) do
    local parent_realpath = parent:abspath()
    if parent_realpath then
      return Path.new(parent_realpath) / self:relative_to(parent)
    end
  end

  return self
end

--- Get OS stat results.
---
---@return table|?
Path.stat = function(self)
  local realpath = self:abspath()
  if realpath then
    local stat, _ = vim.uv.fs_stat(realpath)
    return stat
  end
end

--- Check if the path points to an existing file or directory.
---
---@return boolean
Path.exists = function(self)
  local stat = self:stat()
  return stat ~= nil
end

--- Check if the path points to an existing file.
---
---@return boolean
Path.is_file = function(self)
  local stat = self:stat()
  if stat == nil then
    return false
  else
    return stat.type == "file"
  end
end

--- Check if the path points to an existing directory.
---
---@return boolean
Path.is_dir = function(self)
  local stat = self:stat()
  if stat == nil then
    return false
  else
    return stat.type == "directory"
  end
end

--- Create a new directory at the given path.
---
---@param opts { mode: integer|?, parents: boolean|?, exist_ok: boolean|? }|?
Path.mkdir = function(self, opts)
  opts = opts or {}

  local mode = opts.mode or 448 -- 0700 -> decimal
  ---@diagnostic disable-next-line: undefined-field
  if opts.exists_ok then -- for compat with the plenary.path API.
    opts.exist_ok = true
  end

  if self:is_dir() then
    if not opts.exist_ok then
      error("FileExistsError: " .. self.filename)
    else
      return
    end
  end

  if vim.uv.fs_mkdir(self.filename, mode) then
    return
  end

  if not opts.parents then
    error("FileNotFoundError: " .. tostring(self:parent()))
  end

  local parents = self:parents()
  for i = #parents, 1, -1 do
    if not parents[i]:is_dir() then
      parents[i]:mkdir { exist_ok = true, mode = mode }
    end
  end

  self:mkdir { mode = mode }
end

--- Remove the corresponding directory. This directory must be empty.
Path.rmdir = function(self)
  local resolved = self:resolve { strict = false }

  if not resolved:is_dir() then
    return
  end

  local ok, err_name, err_msg = vim.uv.fs_rmdir(resolved.filename)
  if not ok then
    error(err_name .. ": " .. err_msg)
  end
end

-- TODO: not implemented and not used, after we get to 0.11 we can simply use vim.fs.rm
--- Recursively remove an entire directory and its contents.
Path.rmtree = function(self) end

--- Create a file at this given path.
---
---@param opts { mode: integer|?, exist_ok: boolean|? }|?
Path.touch = function(self, opts)
  opts = opts or {}
  local mode = opts.mode or 420

  local resolved = self:resolve { strict = false }
  if resolved:exists() then
    local new_time = os.time()
    vim.uv.fs_utime(resolved.filename, new_time, new_time)
    return
  end

  local parent = resolved:parent()
  if parent and not parent:exists() then
    error("FileNotFoundError: " .. parent.filename)
  end

  local fd, err_name, err_msg = vim.uv.fs_open(resolved.filename, "w", mode)
  if not fd then
    error(err_name .. ": " .. err_msg)
  end
  vim.uv.fs_close(fd)
end

--- Rename this file or directory to the given target.
---
---@param target obsidian.Path|string
---
---@return obsidian.Path
Path.rename = function(self, target)
  local resolved = self:resolve { strict = false }
  target = Path.new(target)

  local ok, err_name, err_msg = vim.uv.fs_rename(resolved.filename, target.filename)
  if not ok then
    error(err_name .. ": " .. err_msg)
  end

  return target
end

--- Remove the file.
---
---@param opts { missing_ok: boolean|? }|?
Path.unlink = function(self, opts)
  opts = opts or {}

  local resolved = self:resolve { strict = false }

  if not resolved:exists() then
    if not opts.missing_ok then
      error("FileNotFoundError: " .. resolved.filename)
    end
    return
  end

  local ok, err_name, err_msg = vim.uv.fs_unlink(resolved.filename)
  if not ok then
    error(err_name .. ": " .. err_msg)
  end
end

--- Make a path relative to the vault root, if possible, return a string
---
---@param opts { strict: boolean|? }|?
---
---@return string?
Path.vault_relative_path = function(self, opts)
  opts = opts or {}

  -- NOTE: we don't try to resolve the `path` here because that would make the path absolute,
  -- which may result in the wrong relative path if the current working directory is not within
  -- the vault.

  local ok, relative_path = pcall(function()
    return self:relative_to(Obsidian.workspace.root)
  end)

  if ok and relative_path then
    return tostring(relative_path)
  elseif not self:is_absolute() then
    return tostring(self)
  elseif opts.strict then
    error(string.format("failed to resolve '%s' relative to vault root '%s'", self, Obsidian.workspace.root))
  end
end

return Path

local Path = require "obsidian.path"
local abc = require "obsidian.abc"
local yaml = require "obsidian.yaml"
local log = require "obsidian.log"
local util = require "obsidian.util"
local search = require "obsidian.search"
local iter = vim.iter
local enumerate = util.enumerate
local compat = require "obsidian.compat"
local api = require "obsidian.api"
local config = require "obsidian.config"

local SKIP_UPDATING_FRONTMATTER = { "README.md", "CONTRIBUTING.md", "CHANGELOG.md" }

local DEFAULT_MAX_LINES = 500

local CODE_BLOCK_PATTERN = "^%s*```[%w_-]*$"

--- @class obsidian.note.NoteCreationOpts
--- @field notes_subdir string
--- @field note_id_func fun()
--- @field new_notes_location string

--- @class obsidian.note.NoteOpts
--- @field title string|? The note's title
--- @field id string|? An ID to assign the note. If not specified one will be generated.
--- @field dir string|obsidian.Path|? An optional directory to place the note in. Relative paths will be interpreted
--- relative to the workspace / vault root. If the directory doesn't exist it will
--- be created, regardless of the value of the `should_write` option.
--- @field aliases string[]|? Aliases for the note
--- @field tags string[]|?  Tags for this note
--- @field should_write boolean|? Don't write the note to disk
--- @field template string|? The name of the template

---@class obsidian.note.NoteSaveOpts
--- Specify a path to save to. Defaults to `self.path`.
---@field path? string|obsidian.Path
--- Whether to insert/update frontmatter. Defaults to `true`.
---@field insert_frontmatter? boolean
--- Override the frontmatter. Defaults to the result of `self:frontmatter()`.
---@field frontmatter? table
--- A function to update the contents of the note. This takes a list of lines representing the text to be written
--- excluding frontmatter, and returns the lines that will actually be written (again excluding frontmatter).
---@field update_content? fun(lines: string[]): string[]
--- Whether to call |checktime| on open buffers pointing to the written note. Defaults to true.
--- When enabled, Neovim will warn the user if changes would be lost and/or reload the updated file.
--- See `:help checktime` to learn more.
---@field check_buffers? boolean

---@class obsidian.note.NoteWriteOpts
--- Specify a path to save to. Defaults to `self.path`.
---@field path? string|obsidian.Path
--- The name of a template to use if the note file doesn't already exist.
---@field template? string
--- A function to update the contents of the note. This takes a list of lines representing the text to be written
--- excluding frontmatter, and returns the lines that will actually be written (again excluding frontmatter).
---@field update_content? fun(lines: string[]): string[]
--- Whether to call |checktime| on open buffers pointing to the written note. Defaults to true.
--- When enabled, Neovim will warn the user if changes would be lost and/or reload each buffer's content.
--- See `:help checktime` to learn more.
---@field check_buffers? boolean

---@class obsidian.note.HeaderAnchor
---
---@field anchor string
---@field header string
---@field level integer
---@field line integer
---@field parent obsidian.note.HeaderAnchor|?

---@class obsidian.note.Block
---
---@field id string
---@field line integer
---@field block string

--- A class that represents a note within a vault.
---
---@toc_entry obsidian.Note
---
---@class obsidian.Note : obsidian.ABC
---
---@field id string
---@field aliases string[]
---@field title string|?
---@field tags string[]
---@field path obsidian.Path|?
---@field metadata table|?
---@field has_frontmatter boolean|?
---@field frontmatter_end_line integer|?
---@field contents string[]|?
---@field anchor_links table<string, obsidian.note.HeaderAnchor>|?
---@field blocks table<string, obsidian.note.Block>?
---@field alt_alias string|?
---@field bufnr integer|?
local Note = abc.new_class {
  __tostring = function(self)
    return string.format("Note('%s')", self.id)
  end,
}

Note.is_note_obj = function(note)
  if getmetatable(note) == Note.mt then
    return true
  else
    return false
  end
end

--- Generate a unique ID for a new note. This respects the user's `note_id_func` if configured,
--- otherwise falls back to generated a Zettelkasten style ID.
---
--- @param title? string
--- @param path? obsidian.Path
--- @param alt_id_func? (fun(title: string|?, path: obsidian.Path|?): string)
---@return string
local function generate_id(title, path, alt_id_func)
  if alt_id_func ~= nil then
    local new_id = alt_id_func(title, path)
    if new_id == nil or string.len(new_id) == 0 then
      error(string.format("Your 'note_id_func' must return a non-empty string, got '%s'!", tostring(new_id)))
    end
    -- Remote '.md' suffix if it's there (we add that later).
    new_id = new_id:gsub("%.md$", "", 1)
    return new_id
  else
    return require("obsidian.builtin").zettel_id()
  end
end

--- Generate the file path for a new note given its ID, parent directory, and title.
--- This respects the user's `note_path_func` if configured, otherwise essentially falls back to
--- `note_opts.dir / (note_opts.id .. ".md")`.
---
--- @param title string|? The title for the note
--- @param id string The note ID
--- @param dir obsidian.Path The note path
---@return obsidian.Path
---@private
Note._generate_path = function(title, id, dir)
  ---@type obsidian.Path
  local path

  if Obsidian.opts.note_path_func ~= nil then
    path = Path.new(Obsidian.opts.note_path_func { id = id, dir = dir, title = title })
    -- Ensure path is either absolute or inside `opts.dir`.
    -- NOTE: `opts.dir` should always be absolute, but for extra safety we handle the case where
    -- it's not.
    if not path:is_absolute() and (dir:is_absolute() or not dir:is_parent_of(path)) then
      path = dir / path
    end
  else
    path = dir / tostring(id)
  end

  -- Ensure there is only one ".md" suffix. This might arise if `note_path_func`
  -- supplies an unusual implementation returning something like /bad/note/id.md.md.md
  while path.filename:match "%.md$" do
    path.filename = path.filename:gsub("%.md$", "")
  end

  return path:with_suffix(".md", true)
end

--- Selects the strategy to use when resolving the note title, id, and path
--- @param opts obsidian.note.NoteOpts The note creation options
--- @return obsidian.note.NoteCreationOpts The strategy to use for creating the note
--- @private
Note._get_creation_opts = function(opts)
  --- @type obsidian.note.NoteCreationOpts
  local default = {
    notes_subdir = Obsidian.opts.notes_subdir,
    note_id_func = Obsidian.opts.note_id_func,
    new_notes_location = Obsidian.opts.new_notes_location,
  }

  local resolve_template = require("obsidian.templates").resolve_template
  local success, template_path = pcall(resolve_template, opts.template, api.templates_dir())

  if not success then
    return default
  end

  local stem = template_path.stem:lower()

  -- Check if the configuration has a custom key for this template
  for key, cfg in pairs(Obsidian.opts.templates.customizations) do
    if key:lower() == stem then
      return {
        notes_subdir = cfg.notes_subdir,
        note_id_func = cfg.note_id_func,
        new_notes_location = config.NewNotesLocation.notes_subdir,
      }
    end
  end
  return default
end

--- Resolves the title, ID, and path for a new note.
---
---@param title string|?
---@param id string|?
---@param dir string|obsidian.Path|? The directory for the note
---@param strategy obsidian.note.NoteCreationOpts Strategy for resolving note path and title
---@return string|?,string,obsidian.Path
---@private
Note._resolve_title_id_path = function(title, id, dir, strategy)
  if title then
    title = vim.trim(title)
    if title == "" then
      title = nil
    end
  end

  if id then
    id = vim.trim(id)
    if id == "" then
      id = nil
    end
  end

  ---@param s string
  ---@param strict_paths_only boolean
  ---@return string|?, boolean, string|?
  local parse_as_path = function(s, strict_paths_only)
    local is_path = false
    ---@type string|?
    local parent

    if s:match "%.md" then
      -- Remove suffix.
      s = s:sub(1, s:len() - 3)
      is_path = true
    end

    -- Pull out any parent dirs from title.
    local parts = vim.split(s, "/")
    if #parts > 1 then
      s = parts[#parts]
      if not strict_paths_only then
        is_path = true
      end
      parent = table.concat(parts, "/", 1, #parts - 1)
    end

    if s == "" then
      return nil, is_path, parent
    else
      return s, is_path, parent
    end
  end

  local parent, _, title_is_path
  if id then
    id, _, parent = parse_as_path(id, false)
  elseif title then
    title, title_is_path, parent = parse_as_path(title, true)
    if title_is_path then
      id = title
    end
  end

  -- Resolve base directory.
  ---@type obsidian.Path
  local base_dir
  if parent then
    base_dir = Obsidian.dir / parent
  elseif dir ~= nil then
    base_dir = Path.new(dir)
    if not base_dir:is_absolute() then
      base_dir = Obsidian.dir / base_dir
    else
      base_dir = base_dir:resolve()
    end
  else
    local bufpath = Path.buffer(0):resolve()
    if
      strategy.new_notes_location == config.NewNotesLocation.current_dir
      -- note is actually in the workspace.
      and Obsidian.dir:is_parent_of(bufpath)
      -- note is not in dailies folder
      and (
        Obsidian.opts.daily_notes.folder == nil
        or not (Obsidian.dir / Obsidian.opts.daily_notes.folder):is_parent_of(bufpath)
      )
    then
      base_dir = Obsidian.buf_dir or assert(bufpath:parent())
    else
      base_dir = Obsidian.dir
      if strategy.notes_subdir then
        base_dir = base_dir / strategy.notes_subdir
      end
    end
  end

  -- Make sure `base_dir` is absolute at this point.
  assert(base_dir:is_absolute(), ("failed to resolve note directory '%s'"):format(base_dir))

  -- Generate new ID if needed.
  if not id then
    id = generate_id(title, base_dir, strategy.note_id_func)
  end

  dir = base_dir

  -- Generate path.
  local path = Note._generate_path(title, id, dir)

  return title, id, path
end

--- Creates a new note
---
--- @param opts obsidian.note.NoteOpts Options
--- @return obsidian.Note
Note.create = function(opts)
  local new_title, new_id, path =
    Note._resolve_title_id_path(opts.title, opts.id, opts.dir, Note._get_creation_opts(opts))
  opts = vim.tbl_extend("keep", opts, { aliases = {}, tags = {} })

  -- Add the title as an alias.
  --- @type string[]
  local aliases = opts.aliases
  if new_title ~= nil and new_title:len() > 0 and not vim.list_contains(aliases, new_title) then
    aliases[#aliases + 1] = new_title
  end

  local note = Note.new(new_id, aliases, opts.tags, path)

  if new_title then
    note.title = new_title
  end

  -- Ensure the parent directory exists.
  local parent = path:parent()
  assert(parent)
  parent:mkdir { parents = true, exist_ok = true }

  -- Write to disk.
  if opts.should_write then
    note:write { template = opts.template }
  end

  return note
end

--- Instantiates a new Note object
---
--- Keep in mind that you have to call `note:save(...)` to create/update the note on disk.
---
--- @param id string|number
--- @param aliases string[]
--- @param tags string[]
--- @param path string|obsidian.Path|?
--- @return obsidian.Note
Note.new = function(id, aliases, tags, path)
  local self = Note.init()
  self.id = id
  self.aliases = aliases and aliases or {}
  self.tags = tags and tags or {}
  self.path = path and Path.new(path) or nil
  self.metadata = nil
  self.has_frontmatter = nil
  self.frontmatter_end_line = nil
  return self
end

--- Get markdown display info about the note.
---
---@param opts { label: string|?, anchor: obsidian.note.HeaderAnchor|?, block: obsidian.note.Block|? }|?
---
---@return string
Note.display_info = function(self, opts)
  opts = opts and opts or {}

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

  if opts.label ~= nil and string.len(opts.label) > 0 then
    info[#info + 1] = ("%s"):format(opts.label)
    info[#info + 1] = "--------"
  end

  if self.path ~= nil then
    info[#info + 1] = ("**path:** `%s`"):format(self.path)
  end

  info[#info + 1] = ("**id:** `%s`"):format(self.id)

  if #self.aliases > 0 then
    info[#info + 1] = ("**aliases:** '%s'"):format(table.concat(self.aliases, "', '"))
  end

  if #self.tags > 0 then
    info[#info + 1] = ("**tags:** `#%s`"):format(table.concat(self.tags, "`, `#"))
  end

  if opts.anchor or opts.block then
    info[#info + 1] = "--------"

    if opts.anchor then
      info[#info + 1] = ("...\n%s %s\n..."):format(string.rep("#", opts.anchor.level), opts.anchor.header)
    elseif opts.block then
      info[#info + 1] = ("...\n%s\n..."):format(opts.block.block)
    end
  end

  return table.concat(info, "\n")
end

--- Check if the note exists on the file system.
---
---@return boolean
Note.exists = function(self)
  ---@diagnostic disable-next-line: return-type-mismatch
  return self.path ~= nil and self.path:is_file()
end

--- Get the filename associated with the note.
---
---@return string|?
Note.fname = function(self)
  if self.path == nil then
    return nil
  else
    return vim.fs.basename(tostring(self.path))
  end
end

--- Get a list of all of the different string that can identify this note via references,
--- including the ID, aliases, and filename.
---@param opts { lowercase: boolean|? }|?
---@return string[]
Note.reference_ids = function(self, opts)
  opts = opts or {}
  ---@type string[]
  local ref_ids = { tostring(self.id), self:display_name() }
  if self.path then
    table.insert(ref_ids, self.path.name)
    table.insert(ref_ids, self.path.stem)
  end

  vim.list_extend(ref_ids, self.aliases)

  if opts.lowercase then
    ref_ids = vim.tbl_map(string.lower, ref_ids)
  end

  return util.tbl_unique(ref_ids)
end

--- Check if a note has a given alias.
---
---@param alias string
---
---@return boolean
Note.has_alias = function(self, alias)
  return vim.list_contains(self.aliases, alias)
end

--- Check if a note has a given tag.
---
---@param tag string
---
---@return boolean
Note.has_tag = function(self, tag)
  return vim.list_contains(self.tags, tag)
end

--- Add an alias to the note.
---
---@param alias string
---
---@return boolean added True if the alias was added, false if it was already present.
Note.add_alias = function(self, alias)
  if not self:has_alias(alias) then
    table.insert(self.aliases, alias)
    return true
  else
    return false
  end
end

--- Add a tag to the note.
---
---@param tag string
---
---@return boolean added True if the tag was added, false if it was already present.
Note.add_tag = function(self, tag)
  if not self:has_tag(tag) then
    table.insert(self.tags, tag)
    return true
  else
    return false
  end
end

--- Add or update a field in the frontmatter.
---
---@param key string
---@param value any
Note.add_field = function(self, key, value)
  if key == "id" or key == "aliases" or key == "tags" then
    error "Updating field '%s' this way is not allowed. Please update the corresponding attribute directly instead"
  end

  if not self.metadata then
    self.metadata = {}
  end

  self.metadata[key] = value
end

--- Get a field in the frontmatter.
---
---@param key string
---
---@return any result
Note.get_field = function(self, key)
  if key == "id" or key == "aliases" or key == "tags" then
    error "Getting field '%s' this way is not allowed. Please use the corresponding attribute directly instead"
  end

  if not self.metadata then
    return nil
  end

  return self.metadata[key]
end

---@class obsidian.note.LoadOpts
---@field max_lines integer|?
---@field load_contents boolean|?
---@field collect_anchor_links boolean|?
---@field collect_blocks boolean|?

--- Initialize a note from a file.
---
---@param path string|obsidian.Path
---@param opts obsidian.note.LoadOpts|?
---
---@return obsidian.Note
Note.from_file = function(path, opts)
  if path == nil then
    error "note path cannot be nil"
  end
  path = tostring(Path.new(path):resolve { strict = true })
  return Note.from_lines(io.lines(path), path, opts)
end

--- An async version of `.from_file()`, i.e. it needs to be called in an async context.
---
---@param path string|obsidian.Path
---@param opts obsidian.note.LoadOpts|?
---
---@return obsidian.Note
Note.from_file_async = function(path, opts)
  path = Path.new(path):resolve { strict = true }
  local f = io.open(tostring(path), "r")
  assert(f)
  local ok, res = pcall(Note.from_lines, f:lines "*l", path, opts)
  f:close()
  if ok then
    return res
  else
    error(res)
  end
end

--- Like `.from_file_async()` but also returns the contents of the file as a list of lines.
---
---@param path string|obsidian.Path
---@param opts obsidian.note.LoadOpts|?
---
---@return obsidian.Note,string[]
Note.from_file_with_contents_async = function(path, opts)
  opts = vim.tbl_extend("force", opts or {}, { load_contents = true })
  local note = Note.from_file_async(path, opts)
  assert(note.contents ~= nil)
  return note, note.contents
end

--- Initialize a note from a buffer.
---
---@param bufnr integer|?
---@param opts obsidian.note.LoadOpts|?
---
---@return obsidian.Note
Note.from_buffer = function(bufnr, opts)
  bufnr = bufnr or vim.api.nvim_get_current_buf()
  local path = vim.api.nvim_buf_get_name(bufnr)
  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
  local note = Note.from_lines(iter(lines), path, opts)
  note.bufnr = bufnr
  return note
end

--- Get the display name for note.
---
---@return string
Note.display_name = function(self)
  if self.title then
    return self.title
  elseif #self.aliases > 0 then
    return self.aliases[#self.aliases]
  end
  return tostring(self.id)
end

--- Initialize a note from an iterator of lines.
---
---@param lines fun(): string|? | Iter
---@param path string|obsidian.Path
---@param opts obsidian.note.LoadOpts|?
---
---@return obsidian.Note
Note.from_lines = function(lines, path, opts)
  opts = opts or {}
  path = Path.new(path):resolve()

  local max_lines = opts.max_lines or DEFAULT_MAX_LINES

  local id = nil
  local title = nil
  local aliases = {}
  local tags = {}

  ---@type string[]|?
  local contents
  if opts.load_contents then
    contents = {}
  end

  ---@type table<string, obsidian.note.HeaderAnchor>|?
  local anchor_links
  ---@type obsidian.note.HeaderAnchor[]|?
  local anchor_stack
  if opts.collect_anchor_links then
    anchor_links = {}
    anchor_stack = {}
  end

  ---@type table<string, obsidian.note.Block>|?
  local blocks
  if opts.collect_blocks then
    blocks = {}
  end

  ---@param anchor_data obsidian.note.HeaderAnchor
  ---@return obsidian.note.HeaderAnchor|?
  local function get_parent_anchor(anchor_data)
    assert(anchor_links)
    assert(anchor_stack)
    for i = #anchor_stack, 1, -1 do
      local parent = anchor_stack[i]
      if parent.level < anchor_data.level then
        return parent
      end
    end
  end

  ---@param anchor string
  ---@param data obsidian.note.HeaderAnchor|?
  local function format_nested_anchor(anchor, data)
    local out = anchor
    if not data then
      return out
    end

    local parent = data.parent
    while parent ~= nil do
      out = parent.anchor .. out
      data = get_parent_anchor(parent)
      if data then
        parent = data.parent
      else
        parent = nil
      end
    end

    return out
  end

  -- Iterate over lines in the file, collecting frontmatter and parsing the title.
  local frontmatter_lines = {}
  local has_frontmatter, in_frontmatter, at_boundary = false, false, false -- luacheck: ignore (false positive)
  local frontmatter_end_line = nil
  local in_code_block = false
  for line_idx, line in enumerate(lines) do
    line = util.rstrip_whitespace(line)

    if line_idx == 1 and Note._is_frontmatter_boundary(line) then
      has_frontmatter = true
      at_boundary = true
      in_frontmatter = true
    elseif in_frontmatter and Note._is_frontmatter_boundary(line) then
      at_boundary = true
      in_frontmatter = false
      frontmatter_end_line = line_idx
    else
      at_boundary = false
    end

    if string.match(line, CODE_BLOCK_PATTERN) then
      in_code_block = not in_code_block
    end

    if in_frontmatter and not at_boundary then
      table.insert(frontmatter_lines, line)
    elseif not in_frontmatter and not at_boundary and not in_code_block then
      -- Check for title/header and collect anchor link.
      local header_match = util.parse_header(line)
      if header_match then
        if not title and header_match.level == 1 then
          title = header_match.header
        end

        -- Collect anchor link.
        if opts.collect_anchor_links then
          assert(anchor_links)
          assert(anchor_stack)
          -- We collect up to two anchor for each header. One standalone, e.g. '#header1', and
          -- one with the parents, e.g. '#header1#header2'.
          -- This is our standalone one:
          ---@type obsidian.note.HeaderAnchor
          local data = {
            anchor = header_match.anchor,
            line = line_idx,
            header = header_match.header,
            level = header_match.level,
          }
          data.parent = get_parent_anchor(data)

          anchor_links[header_match.anchor] = data
          table.insert(anchor_stack, data)

          -- Now if there's a parent we collect the nested version. All of the data will be the same
          -- except the anchor key.
          if data.parent ~= nil then
            local nested_anchor = format_nested_anchor(header_match.anchor, data)
            anchor_links[nested_anchor] = vim.tbl_extend("force", data, { anchor = nested_anchor })
          end
        end
      end

      -- Check for block.
      if opts.collect_blocks then
        local block = util.parse_block(line)
        if block then
          blocks[block] = { id = block, line = line_idx, block = line }
        end
      end
    end

    -- Collect contents.
    if contents ~= nil then
      table.insert(contents, line)
    end

    -- Check if we can stop reading lines now.
    if
      line_idx > max_lines
      or (title and not opts.load_contents and not opts.collect_anchor_links and not opts.collect_blocks)
    then
      break
    end
  end

  if title ~= nil then
    -- Remove references and links from title
    title = search.replace_refs(title)
  end

  -- Parse the frontmatter YAML.
  local metadata = nil
  if #frontmatter_lines > 0 then
    local frontmatter = table.concat(frontmatter_lines, "\n")
    local ok, data = pcall(yaml.loads, frontmatter)
    if type(data) ~= "table" then
      data = {}
    end
    if ok then
      ---@diagnostic disable-next-line: param-type-mismatch
      for k, v in pairs(data) do
        if k == "id" then
          if type(v) == "string" or type(v) == "number" then
            id = v
          else
            log.warn("Invalid 'id' in frontmatter for " .. tostring(path))
          end
        elseif k == "aliases" then
          if type(v) == "table" then
            for alias in iter(v) do
              if type(alias) == "string" then
                table.insert(aliases, alias)
              else
                log.warn(
                  "Invalid alias value found in frontmatter for "
                    .. tostring(path)
                    .. ". Expected string, found "
                    .. type(alias)
                    .. "."
                )
              end
            end
          elseif type(v) == "string" then
            table.insert(aliases, v)
          else
            log.warn("Invalid 'aliases' in frontmatter for " .. tostring(path))
          end
        elseif k == "tags" then
          if type(v) == "table" then
            for tag in iter(v) do
              if type(tag) == "string" then
                table.insert(tags, tag)
              else
                log.warn(
                  "Invalid tag value found in frontmatter for "
                    .. tostring(path)
                    .. ". Expected string, found "
                    .. type(tag)
                    .. "."
                )
              end
            end
          elseif type(v) == "string" then
            tags = vim.split(v, " ")
          else
            log.warn("Invalid 'tags' in frontmatter for '%s'", path)
          end
        else
          if metadata == nil then
            metadata = {}
          end
          metadata[k] = v
        end
      end
    end
  end

  -- ID should default to the filename without the extension.
  if id == nil or id == path.name then
    id = path.stem
  end
  assert(id)

  local n = Note.new(id, aliases, tags, path)
  n.title = title
  n.metadata = metadata
  n.has_frontmatter = has_frontmatter
  n.frontmatter_end_line = frontmatter_end_line
  n.contents = contents
  n.anchor_links = anchor_links
  n.blocks = blocks
  return n
end

--- Check if a line matches a frontmatter boundary.
---
---@param line string
---
---@return boolean
---
---@private
Note._is_frontmatter_boundary = function(line)
  return line:match "^---+$" ~= nil
end

--- Get the frontmatter table to save.
---
---@return table
Note.frontmatter = function(self)
  local out = { id = self.id, aliases = self.aliases, tags = self.tags }
  if self.metadata ~= nil and not vim.tbl_isempty(self.metadata) then
    for k, v in pairs(self.metadata) do
      out[k] = v
    end
  end
  return out
end

--- Get frontmatter lines that can be written to a buffer.
---
---@param eol boolean|?
---@param frontmatter table|?
---
---@return string[]
Note.frontmatter_lines = function(self, eol, frontmatter)
  local new_lines = { "---" }

  local frontmatter_ = frontmatter and frontmatter or self:frontmatter()
  if vim.tbl_isempty(frontmatter_) then
    return {}
  end

  for line in
    iter(yaml.dumps_lines(frontmatter_, function(a, b)
      local a_idx = nil
      local b_idx = nil
      for i, k in ipairs { "id", "aliases", "tags" } do
        if a == k then
          a_idx = i
        end
        if b == k then
          b_idx = i
        end
      end
      if a_idx ~= nil and b_idx ~= nil then
        return a_idx < b_idx
      elseif a_idx ~= nil then
        return true
      elseif b_idx ~= nil then
        return false
      else
        return a < b
      end
    end))
  do
    table.insert(new_lines, line)
  end

  table.insert(new_lines, "---")
  if not self.has_frontmatter then
    -- Make sure there's an empty line between end of the frontmatter and the contents.
    table.insert(new_lines, "")
  end

  if eol then
    return vim.tbl_map(function(l)
      return l .. "\n"
    end, new_lines)
  else
    return new_lines
  end
end

--- Update the frontmatter in a buffer for the note.
---
---@param bufnr integer|?
---
---@return boolean updated If the the frontmatter was updated.
Note.update_frontmatter = function(self, bufnr)
  if not self:should_save_frontmatter() then
    return false
  end

  local frontmatter = nil
  if Obsidian.opts.note_frontmatter_func ~= nil then
    frontmatter = Obsidian.opts.note_frontmatter_func(self)
  end
  return self:save_to_buffer { bufnr = bufnr, frontmatter = frontmatter }
end

--- Checks if the parameter note is in the blacklist of files which shouldn't have
--- frontmatter applied
---
--- @param note obsidian.Note The note
--- @return boolean true if so
local is_in_frontmatter_blacklist = function(note)
  local fname = note:fname()
  return (fname ~= nil and vim.list_contains(SKIP_UPDATING_FRONTMATTER, fname))
end

--- Determines whether a note's frontmatter is managed by obsidian.nvim.
---
---@return boolean
Note.should_save_frontmatter = function(self)
  -- Check if the note is a template.
  local templates_dir = api.templates_dir()
  if templates_dir ~= nil then
    templates_dir = templates_dir:resolve()
    for _, parent in ipairs(self.path:parents()) do
      if parent == templates_dir then
        return false
      end
    end
  end

  if is_in_frontmatter_blacklist(self) then
    return false
  elseif type(Obsidian.opts.disable_frontmatter) == "boolean" then
    return not Obsidian.opts.disable_frontmatter
  elseif type(Obsidian.opts.disable_frontmatter) == "function" then
    return not Obsidian.opts.disable_frontmatter(self.path:vault_relative_path { strict = true })
  else
    return true
  end
end

--- Write the note to disk.
---
---@param opts? obsidian.note.NoteWriteOpts
---@return obsidian.Note
Note.write = function(self, opts)
  local Template = require "obsidian.templates"
  opts = vim.tbl_extend("keep", opts or {}, { check_buffers = true })

  local path = assert(self.path, "A path must be provided")
  path = Path.new(path)

  ---@type string
  local verb
  if path:is_file() then
    verb = "Updated"
  else
    verb = "Created"
    if opts.template ~= nil then
      self = Template.clone_template {
        type = "clone_template",
        template_name = opts.template,
        destination_path = path,
        template_opts = Obsidian.opts.templates,
        templates_dir = assert(api.templates_dir(), "Templates folder is not defined or does not exist"),
        partial_note = self,
      }
    end
  end

  local frontmatter = nil
  if Obsidian.opts.note_frontmatter_func ~= nil then
    frontmatter = Obsidian.opts.note_frontmatter_func(self)
  end

  self:save {
    path = path,
    insert_frontmatter = self:should_save_frontmatter(),
    frontmatter = frontmatter,
    update_content = opts.update_content,
    check_buffers = opts.check_buffers,
  }

  log.info("%s note '%s' at '%s'", verb, self.id, self.path:vault_relative_path(self.path) or self.path)

  return self
end

--- Save the note to a file.
--- In general this only updates the frontmatter and header, leaving the rest of the contents unchanged
--- unless you use the `update_content()` callback.
---
---@param opts? obsidian.note.NoteSaveOpts
Note.save = function(self, opts)
  opts = vim.tbl_extend("keep", opts or {}, { check_buffers = true })

  if self.path == nil then
    error "a path is required"
  end

  local save_path = Path.new(assert(opts.path or self.path)):resolve()
  assert(save_path:parent()):mkdir { parents = true, exist_ok = true }

  -- Read contents from existing file or buffer, if there is one.
  -- TODO: check for open buffer?
  ---@type string[]
  local content = {}
  ---@type string[]
  local existing_frontmatter = {}
  if self.path ~= nil and self.path:is_file() then
    -- with(open(tostring(self.path)), function(reader)
    local in_frontmatter, at_boundary = false, false -- luacheck: ignore (false positive)
    for idx, line in enumerate(io.lines(tostring(self.path))) do
      if idx == 1 and Note._is_frontmatter_boundary(line) then
        at_boundary = true
        in_frontmatter = true
      elseif in_frontmatter and Note._is_frontmatter_boundary(line) then
        at_boundary = true
        in_frontmatter = false
      else
        at_boundary = false
      end

      if not in_frontmatter and not at_boundary then
        table.insert(content, line)
      else
        table.insert(existing_frontmatter, line)
      end
    end
    -- end)
  elseif self.title ~= nil then
    -- Add a header.
    table.insert(content, "# " .. self.title)
  end

  -- Pass content through callback.
  if opts.update_content then
    content = opts.update_content(content)
  end

  ---@type string[]
  local new_lines
  if opts.insert_frontmatter ~= false then
    -- Replace frontmatter.
    new_lines = compat.flatten { self:frontmatter_lines(false, opts.frontmatter), content }
  else
    -- Use existing frontmatter.
    new_lines = compat.flatten { existing_frontmatter, content }
  end

  util.write_file(tostring(save_path), table.concat(new_lines, "\n"))

  if opts.check_buffers then
    -- `vim.fn.bufnr` returns the **max** bufnr loaded from the same path.
    if vim.fn.bufnr(save_path.filename) ~= -1 then
      -- But we want to call |checktime| on **all** buffers loaded from the path.
      vim.cmd.checktime(save_path.filename)
    end
  end
end

--- Write the note to a buffer.
---
---@param opts { bufnr: integer|?, template: string|? }|? Options.
---
--- Options:
---  - `bufnr`: Override the buffer to write to. Defaults to current buffer.
---  - `template`: The name of a template to use if the buffer is empty.
---
---@return boolean updated If the buffer was updated.
Note.write_to_buffer = function(self, opts)
  local Template = require "obsidian.templates"
  opts = opts or {}

  if opts.template and api.buffer_is_empty(opts.bufnr) then
    self = Template.insert_template {
      type = "insert_template",
      template_name = opts.template,
      template_opts = Obsidian.opts.templates,
      templates_dir = assert(api.templates_dir(), "Templates folder is not defined or does not exist"),
      location = api.get_active_window_cursor_location(),
      partial_note = self,
    }
  end

  local frontmatter = nil
  local should_save_frontmatter = self:should_save_frontmatter()
  if should_save_frontmatter and Obsidian.opts.note_frontmatter_func ~= nil then
    frontmatter = Obsidian.opts.note_frontmatter_func(self)
  end

  return self:save_to_buffer {
    bufnr = opts.bufnr,
    insert_frontmatter = should_save_frontmatter,
    frontmatter = frontmatter,
  }
end

--- Save the note to the buffer
---
---@param opts { bufnr: integer|?, insert_frontmatter: boolean|?, frontmatter: table|? }|? Options.
---
---@return boolean updated True if the buffer lines were updated, false otherwise.
Note.save_to_buffer = function(self, opts)
  opts = opts or {}

  local bufnr = opts.bufnr
  if not bufnr then
    bufnr = self.bufnr or 0
  end

  local cur_buf_note = Note.from_buffer(bufnr)

  ---@type string[]
  local new_lines
  if opts.insert_frontmatter ~= false then
    new_lines = self:frontmatter_lines(nil, opts.frontmatter)
  else
    new_lines = {}
  end

  if api.buffer_is_empty(bufnr) and self.title ~= nil then
    table.insert(new_lines, "# " .. self.title)
  end

  ---@type string[]
  local cur_lines = {}
  if cur_buf_note.frontmatter_end_line ~= nil then
    cur_lines = vim.api.nvim_buf_get_lines(bufnr, 0, cur_buf_note.frontmatter_end_line, false)
  end

  if not vim.deep_equal(cur_lines, new_lines) then
    vim.api.nvim_buf_set_lines(
      bufnr,
      0,
      cur_buf_note.frontmatter_end_line and cur_buf_note.frontmatter_end_line or 0,
      false,
      new_lines
    )
    return true
  else
    return false
  end
end

--- Try to resolve an anchor link to a line number in the note's file.
---
---@param anchor_link string
---@return obsidian.note.HeaderAnchor|?
Note.resolve_anchor_link = function(self, anchor_link)
  anchor_link = util.standardize_anchor(anchor_link)

  if self.anchor_links ~= nil then
    return self.anchor_links[anchor_link]
  end

  assert(self.path, "'note.path' is not set")
  local n = Note.from_file(self.path, { collect_anchor_links = true })
  self.anchor_links = n.anchor_links
  return n:resolve_anchor_link(anchor_link)
end

--- Try to resolve a block identifier.
---
---@param block_id string
---
---@return obsidian.note.Block|?
Note.resolve_block = function(self, block_id)
  block_id = util.standardize_block(block_id)

  if self.blocks ~= nil then
    return self.blocks[block_id]
  end

  assert(self.path, "'note.path' is not set")
  local n = Note.from_file(self.path, { collect_blocks = true })
  self.blocks = n.blocks
  return self.blocks[block_id]
end

--- Open a note in a buffer.
---@param opts { line: integer|?, col: integer|?, open_strategy: obsidian.config.OpenStrategy|?, sync: boolean|?, callback: fun(bufnr: integer)|? }|?
Note.open = function(self, opts)
  opts = opts or {}

  local path = self.path

  local function open_it()
    local open_cmd = api.get_open_strategy(opts.open_strategy and opts.open_strategy or Obsidian.opts.open_notes_in)
    ---@cast path obsidian.Path
    local bufnr = api.open_buffer(path, { line = opts.line, col = opts.col, cmd = open_cmd })
    if opts.callback then
      opts.callback(bufnr)
    end
  end

  if opts.sync then
    open_it()
  else
    vim.schedule(open_it)
  end
end

return Note

local log = require "obsidian.log"
local daily = require "obsidian.daily"

---@param arg string
---@return number
local function parse_offset(arg)
  if vim.startswith(arg, "+") then
    return assert(tonumber(string.sub(arg, 2)), string.format("invalid offset '%'", arg))
  elseif vim.startswith(arg, "-") then
    return -assert(tonumber(string.sub(arg, 2)), string.format("invalid offset '%s'", arg))
  else
    return assert(tonumber(arg), string.format("invalid offset '%s'", arg))
  end
end

---@param data CommandArgs
return function(_, data)
  local offset_start = -5
  local offset_end = 0

  if data.fargs and #data.fargs > 0 then
    if #data.fargs == 1 then
      local offset = parse_offset(data.fargs[1])
      if offset >= 0 then
        offset_end = offset
      else
        offset_start = offset
      end
    elseif #data.fargs == 2 then
      local offsets = vim.tbl_map(parse_offset, data.fargs)
      table.sort(offsets)
      offset_start = offsets[1]
      offset_end = offsets[2]
    else
      error ":Obsidian dailies expected at most 2 arguments"
    end
  end

  local picker = Obsidian.picker
  if not picker then
    log.err "No picker configured"
    return
  end

  ---@type obsidian.PickerEntry[]
  local dailies = {}
  for offset = offset_end, offset_start, -1 do
    local datetime = os.time() + (offset * 3600 * 24)
    local daily_note_path = daily.daily_note_path(datetime)
    local daily_note_alias = tostring(os.date(Obsidian.opts.daily_notes.alias_format or "%A %B %-d, %Y", datetime))
    if offset == 0 then
      daily_note_alias = daily_note_alias .. " @today"
    elseif offset == -1 then
      daily_note_alias = daily_note_alias .. " @yesterday"
    elseif offset == 1 then
      daily_note_alias = daily_note_alias .. " @tomorrow"
    end
    if not daily_note_path:is_file() then
      daily_note_alias = daily_note_alias .. " ➡ create"
    end
    dailies[#dailies + 1] = {
      value = offset,
      display = daily_note_alias,
      ordinal = daily_note_alias,
      filename = tostring(daily_note_path),
    }
  end

  picker:pick(dailies, {
    prompt_title = "Dailies",
    callback = function(offset)
      local note = daily.daily(offset, {})
      note:open()
    end,
  })
end

local M = {}
local VERSION = require "obsidian.version"
local api = require "obsidian.api"

local error = vim.health.error
local warn = vim.health.warn
local ok = vim.health.ok

local function info(...)
  local t = { ... }
  local format = table.remove(t, 1)
  local str = #t == 0 and format or string.format(format, unpack(t))
  return ok(str)
end

---@private
---@param name string
local function start(name)
  vim.health.start(string.format("obsidian.nvim [%s]", name))
end

---@param plugin string
---@param optional boolean
---@return boolean
local function has_plugin(plugin, optional)
  local plugin_info = api.get_plugin_info(plugin)
  if plugin_info then
    info("%s: %s", plugin, plugin_info.commit or "unknown")
    return true
  else
    if not optional then
      vim.health.error(" " .. plugin .. " not installed")
    end
    return false
  end
end

local function has_executable(name, optional)
  if vim.fn.executable(name) == 1 then
    local version = api.get_external_dependency_info(name)
    if version then
      info("%s: %s", name, version)
    else
      info("%s: found", name)
    end
    return true
  else
    if not optional then
      error(string.format("%s not found", name))
    end
    return false
  end
end

---@param plugins string[]
local function has_one_of(plugins)
  local found
  for _, name in ipairs(plugins) do
    if has_plugin(name, true) then
      found = true
    end
  end
  if not found then
    vim.health.warn("It is recommended to install at least one of " .. vim.inspect(plugins))
  end
end

---@param plugins string[]
local function has_one_of_executable(plugins)
  local found
  for _, name in ipairs(plugins) do
    if has_executable(name, true) then
      found = true
    end
  end
  if not found then
    vim.health.warn("It is recommended to install at least one of " .. vim.inspect(plugins))
  end
end

---@param minimum string
---@param recommended string
local function neovim(minimum, recommended)
  if vim.fn.has("nvim-" .. minimum) == 0 then
    error("neovim < " .. minimum)
  elseif vim.fn.has("nvim-" .. recommended) == 0 then
    warn("neovim < " .. recommended .. " some features will not work")
  else
    ok("neovim >= " .. recommended)
  end
end

function M.check()
  local os = api.get_os()
  neovim("0.10", "0.11")
  start "Version"
  info("obsidian.nvim v%s (%s)", VERSION, api.get_plugin_info("obsidian.nvim").commit)

  start "Environment"
  info("operating system: %s", os)

  start "Config"
  info("  • dir: %s", Obsidian.dir)

  start "Pickers"

  has_one_of {
    "telescope.nvim",
    "fzf-lua",
    "mini.nvim",
    "mini.pick",
    "snacks.nvim",
  }

  start "Completion"

  has_one_of {
    "nvim-cmp",
    "blink.cmp",
  }

  start "Dependencies"
  has_executable("rg", false)
  has_plugin("plenary.nvim", false)

  if os == api.OSType.Wsl then
    has_executable("wsl-open", true)
  elseif os == api.OSType.Linux then
    has_one_of_executable {
      "xclip",
      "wl-paste",
    }
  elseif os == api.OSType.Darwin then
    has_executable("pngpaste", true)
  end
end

return M

local util = require "obsidian.util"
local obsidian = require "obsidian"

local M = {}

M.injected_once = false

M.providers = {
  { name = "obsidian", module = "obsidian.completion.sources.blink.refs" },
  { name = "obsidian_tags", module = "obsidian.completion.sources.blink.tags" },
}

local function add_provider(blink, provider_name, proivder_module)
  local add_source_provider = blink.add_source_provider or blink.add_provider
  add_source_provider(provider_name, {
    name = provider_name,
    module = proivder_module,
    async = true,
    opts = {},
    enabled = function()
      -- Enable only in markdown buffers.
      return vim.tbl_contains({ "markdown" }, vim.bo.filetype)
        and vim.bo.buftype ~= "prompt"
        and vim.b.completion ~= false
    end,
  })
end

-- Ran once on the plugin startup
---@param opts obsidian.config.ClientOpts
function M.register_providers(opts)
  local blink = require "blink.cmp"

  if opts.completion.create_new then
    table.insert(M.providers, { name = "obsidian_new", module = "obsidian.completion.sources.blink.new" })
  end

  for _, provider in pairs(M.providers) do
    add_provider(blink, provider.name, provider.module)
  end
end

local function add_element_to_list_if_not_exists(list, element)
  if not vim.tbl_contains(list, element) then
    table.insert(list, 1, element)
  end
end

local function should_return_if_not_in_workspace()
  local current_file_path = vim.api.nvim_buf_get_name(0)
  local buf_dir = vim.fs.dirname(current_file_path)

  local workspace = obsidian.Workspace.get_workspace_for_dir(buf_dir, Obsidian.opts.workspaces)
  if not workspace then
    return true
  else
    return false
  end
end

local function log_unexpected_type(config_path, unexpected_type, expected_type)
  vim.notify(
    "blink.cmp's `"
      .. config_path
      .. "` configuration appears to be an '"
      .. unexpected_type
      .. "' type, but it "
      .. "should be '"
      .. expected_type
      .. "'. Obsidian won't update this configuration, and "
      .. "completion won't work with blink.cmp",
    vim.log.levels.ERROR
  )
end

---Attempts to inject the Obsidian sources into per_filetype if that's what the user seems to use for markdown
---@param blink_sources_per_filetype table<string, (fun():string[])|(string[])>
---@return boolean true if it obsidian sources were injected into the sources.per_filetype
local function try_inject_blink_sources_into_per_filetype(blink_sources_per_filetype)
  -- If the per_filetype is an empty object, then it's probably not utilized by the user
  if vim.deep_equal(blink_sources_per_filetype, {}) then
    return false
  end

  local markdown_config = blink_sources_per_filetype["markdown"]

  -- If the markdown key is not used, then per_filetype it's probably not utilized by the user
  if markdown_config == nil then
    return false
  end

  local markdown_config_type = type(markdown_config)
  if markdown_config_type == "table" and util.islist(markdown_config) then
    for _, provider in pairs(M.providers) do
      add_element_to_list_if_not_exists(markdown_config, provider.name)
    end
    return true
  elseif markdown_config_type == "function" then
    local original_func = markdown_config
    markdown_config = function()
      local original_results = original_func()

      if should_return_if_not_in_workspace() then
        return original_results
      end

      for _, provider in pairs(M.providers) do
        add_element_to_list_if_not_exists(original_results, provider.name)
      end
      return original_results
    end

    -- Overwrite the original config function with the newly generated one
    require("blink.cmp.config").sources.per_filetype["markdown"] = markdown_config
    return true
  else
    log_unexpected_type(
      ".sources.per_filetype['markdown']",
      markdown_config_type,
      "a list or a function that returns a list of sources"
    )
    return true -- logged the error, returns as if this was successful to avoid further errors
  end
end

---Attempts to inject the Obsidian sources into default if that's what the user seems to use for markdown
---@param blink_sources_default (fun():string[])|(string[])
---@return boolean true if it obsidian sources were injected into the sources.default
local function try_inject_blink_sources_into_default(blink_sources_default)
  local blink_default_type = type(blink_sources_default)
  if blink_default_type == "function" then
    local original_func = blink_sources_default
    blink_sources_default = function()
      local original_results = original_func()

      if should_return_if_not_in_workspace() then
        return original_results
      end

      for _, provider in pairs(M.providers) do
        add_element_to_list_if_not_exists(original_results, provider.name)
      end
      return original_results
    end

    -- Overwrite the original config function with the newly generated one
    require("blink.cmp.config").sources.default = blink_sources_default
    return true
  elseif blink_default_type == "table" and util.islist(blink_sources_default) then
    for _, provider in pairs(M.providers) do
      add_element_to_list_if_not_exists(blink_sources_default, provider.name)
    end

    return true
  elseif blink_default_type == "table" then
    log_unexpected_type(".sources.default", blink_default_type, "a list")
    return true -- logged the error, returns as if this was successful to avoid further errors
  else
    log_unexpected_type(".sources.default", blink_default_type, "a list or a function that returns a list")
    return true -- logged the error, returns as if this was successful to avoid further errors
  end
end

-- Triggered for each opened markdown buffer that's in a workspace. nvm_cmp had the capability to configure the sources
-- per buffer, but blink.cmp doesn't have that capability. Instead, we have to inject the sources into the global
-- configuration and set a boolean on the module to return early the next time this function is called.
--
-- In-case the user used functions to configure their sources, the completion will properly work just for the markdown
-- files that are in a workspace. Otherwise, the completion will work for all markdown files.
---@param opts obsidian.config.ClientOpts
function M.inject_sources(opts)
  if M.injected_once then
    return
  end

  M.injected_once = true

  local blink_config = require "blink.cmp.config"
  -- 'per_filetype' sources has priority over 'default' sources.
  -- 'per_filetype' can be a table or a function which returns a table (["filetype"] = { "a", "b" })
  -- 'per_filetype' has the default value of {} (even if it's not configured by the user)
  local blink_sources_per_filetype = blink_config.sources.per_filetype
  if try_inject_blink_sources_into_per_filetype(blink_sources_per_filetype) then
    return
  end

  -- 'default' can be a list/array or a function which returns a list/array ({ "a", "b"})
  local blink_sources_default = blink_config.sources["default"]
  if try_inject_blink_sources_into_default(blink_sources_default) then
    return
  end
end

return M

local Path = require "obsidian.path"
local Note = require "obsidian.note"
local util = require "obsidian.util"
local api = require "obsidian.api"

local M = {}

--- Resolve a template name to a path.
---
---@param template_name string|obsidian.Path
---@param templates_dir obsidian.Path
---
---@return obsidian.Path
M.resolve_template = function(template_name, templates_dir)
  ---@type obsidian.Path|?
  local template_path
  local paths_to_check = { templates_dir / tostring(template_name), Path:new(template_name) }
  for _, path in ipairs(paths_to_check) do
    if path:is_file() then
      template_path = path
      break
    elseif not vim.endswith(tostring(path), ".md") then
      local path_with_suffix = Path:new(tostring(path) .. ".md")
      if path_with_suffix:is_file() then
        template_path = path_with_suffix
        break
      end
    end
  end

  if template_path == nil then
    error(string.format("Template '%s' not found", template_name))
  end

  return template_path
end

--- Substitute variables inside the given text.
---
---@param text string
---@param ctx obsidian.TemplateContext
---
---@return string
M.substitute_template_variables = function(text, ctx)
  local methods = vim.deepcopy(ctx.template_opts.substitutions or {})

  if not methods["date"] then
    methods["date"] = function()
      local date_format = ctx.template_opts.date_format or "%Y-%m-%d"
      return tostring(os.date(date_format))
    end
  end

  if not methods["time"] then
    methods["time"] = function()
      local time_format = ctx.template_opts.time_format or "%H:%M"
      return tostring(os.date(time_format))
    end
  end

  if not methods["title"] and ctx.partial_note then
    methods["title"] = ctx.partial_note.title or ctx.partial_note:display_name()
  end

  if not methods["id"] and ctx.partial_note then
    methods["id"] = tostring(ctx.partial_note.id)
  end

  if not methods["path"] and ctx.partial_note and ctx.partial_note.path then
    methods["path"] = tostring(ctx.partial_note.path)
  end

  -- Replace known variables.
  for key, subst in pairs(methods) do
    while true do
      local m_start, m_end = string.find(text, "{{" .. key .. "}}", nil, true)
      if not m_start or not m_end then
        break
      end
      ---@type string
      local value
      if type(subst) == "string" then
        value = subst
      else
        value = subst(ctx)
        -- cache the result
        methods[key] = value
      end
      text = string.sub(text, 1, m_start - 1) .. value .. string.sub(text, m_end + 1)
    end
  end

  -- Find unknown variables and prompt for them.
  for m_start, m_end in util.gfind(text, "{{[^}]+}}") do
    local key = vim.trim(string.sub(text, m_start + 2, m_end - 2))
    local value = api.input(string.format("Enter value for '%s' (<cr> to skip): ", key))
    if value and string.len(value) > 0 then
      text = string.sub(text, 1, m_start - 1) .. value .. string.sub(text, m_end + 1)
    end
  end

  return text
end

--- Clone template to a new note.
---
---@param ctx obsidian.CloneTemplateContext
---
---@return obsidian.Note
M.clone_template = function(ctx)
  local note_path = Path.new(ctx.destination_path)
  assert(note_path:parent()):mkdir { parents = true, exist_ok = true }

  local template_path = M.resolve_template(ctx.template_name, ctx.templates_dir)

  local template_file, read_err = io.open(tostring(template_path), "r")
  if not template_file then
    error(string.format("Unable to read template at '%s': %s", template_path, tostring(read_err)))
  end

  local note_file, write_err = io.open(tostring(note_path), "w")
  if not note_file then
    error(string.format("Unable to write note at '%s': %s", note_path, tostring(write_err)))
  end

  for line in template_file:lines "L" do
    line = M.substitute_template_variables(line, ctx)
    note_file:write(line)
  end

  assert(template_file:close())
  assert(note_file:close())

  local new_note = Note.from_file(note_path)

  if ctx.partial_note then
    -- Transfer fields from `ctx.partial_note`.
    new_note.id = ctx.partial_note.id
    if new_note.title == nil then
      new_note.title = ctx.partial_note.title
    end
    for _, alias in ipairs(ctx.partial_note.aliases) do
      new_note:add_alias(alias)
    end
    for _, tag in ipairs(ctx.partial_note.tags) do
      new_note:add_tag(tag)
    end
  end

  return new_note
end

---Insert a template at the given location.
---
---@param ctx obsidian.InsertTemplateContext
---
---@return obsidian.Note
M.insert_template = function(ctx)
  local buf, win, row, _ = unpack(ctx.location)
  if ctx.partial_note == nil then
    ctx.partial_note = Note.from_buffer(buf)
  end

  local template_path = M.resolve_template(ctx.template_name, ctx.templates_dir)

  local insert_lines = {}
  local template_file = io.open(tostring(template_path), "r")
  if template_file then
    local lines = template_file:lines()
    for line in lines do
      local new_lines = M.substitute_template_variables(line, ctx)
      if string.find(new_lines, "[\r\n]") then
        local line_start = 1
        for line_end in util.gfind(new_lines, "[\r\n]") do
          local new_line = string.sub(new_lines, line_start, line_end - 1)
          table.insert(insert_lines, new_line)
          line_start = line_end + 1
        end
        local last_line = string.sub(new_lines, line_start)
        if string.len(last_line) > 0 then
          table.insert(insert_lines, last_line)
        end
      else
        table.insert(insert_lines, new_lines)
      end
    end
    template_file:close()
  else
    error(string.format("Template file '%s' not found", template_path))
  end

  vim.api.nvim_buf_set_lines(buf, row - 1, row - 1, false, insert_lines)
  local new_cursor_row, _ = unpack(vim.api.nvim_win_get_cursor(win))
  vim.api.nvim_win_set_cursor(0, { new_cursor_row, 0 })

  require("obsidian.ui").update(0)

  return Note.from_buffer(buf)
end

return M

local util = require "obsidian.util"
local api = require "obsidian.api"

return function()
  local note = assert(api.current_note(0, { collect_anchor_links = true }))

  ---@type obsidian.PickerEntry[]
  local picker_entries = {}
  for _, anchor in pairs(note.anchor_links) do
    local display = string.rep("#", anchor.level) .. " " .. anchor.header
    table.insert(
      picker_entries,
      { value = display, display = display, filename = tostring(note.path), lnum = anchor.line }
    )
  end

  -- De-duplicate and sort.
  picker_entries = util.tbl_unique(picker_entries)
  table.sort(picker_entries, function(a, b)
    return a.lnum < b.lnum
  end)

  local picker = assert(Obsidian.picker)
  picker:pick(picker_entries, { prompt_title = "Table of Contents" })
end

local compat = require "obsidian.compat"
local ts, string, table = vim.treesitter, string, table
local util = {}

setmetatable(util, {
  __index = function(_, k)
    return require("obsidian.api")[k] or require("obsidian.builtin")[k]
  end,
})

-------------------
--- File tools ----
-------------------

---@param file string
---@param contents string
util.write_file = function(file, contents)
  local fd = assert(io.open(file, "w+"))
  fd:write(contents)
  fd:close()
end

-------------------
--- Iter tools ----
-------------------

---Create an enumeration iterator over an iterable.
---@param iterable table|string|function
---@return function
util.enumerate = function(iterable)
  local iterator = vim.iter(iterable)
  local i = 0

  return function()
    local next = iterator()
    if next == nil then
      return nil, nil
    else
      i = i + 1
      return i, next
    end
  end
end

---Zip two iterables together.
---@param iterable1 table|string|function
---@param iterable2 table|string|function
---@return function
util.zip = function(iterable1, iterable2)
  local iterator1 = vim.iter(iterable1)
  local iterator2 = vim.iter(iterable2)

  return function()
    local next1 = iterator1()
    local next2 = iterator2()
    if next1 == nil or next2 == nil then
      return nil
    else
      return next1, next2
    end
  end
end

-------------------
--- Table tools ---
-------------------

---Check if an object is an array-like table.
--- TODO: after 0.12 replace with vim.islist
---
---@param t any
---@return boolean
util.islist = function(t)
  return compat.is_list(t)
end

---Return a new list table with only the unique values of the original table.
---
---@param t table
---@return any[]
util.tbl_unique = function(t)
  local found = {}
  for _, val in pairs(t) do
    found[val] = true
  end
  return vim.tbl_keys(found)
end

--------------------
--- String Tools ---
--------------------

---Iterate over all matches of 'pattern' in 's'. 'gfind' is to 'find' as 'gsub' is to 'sub'.
---@param s string
---@param pattern string
---@param init integer|?
---@param plain boolean|?
util.gfind = function(s, pattern, init, plain)
  init = init and init or 1

  return function()
    if init < #s then
      local m_start, m_end = string.find(s, pattern, init, plain)
      if m_start ~= nil and m_end ~= nil then
        init = m_end + 1
        return m_start, m_end
      end
    end
    return nil
  end
end

local char_to_hex = function(c)
  return string.format("%%%02X", string.byte(c))
end

--- Encode a string into URL-safe version.
---
---@param str string
---@param opts { keep_path_sep: boolean|? }|?
---
---@return string
util.urlencode = function(str, opts)
  opts = opts or {}
  local url = str
  url = url:gsub("\n", "\r\n")
  url = url:gsub("([%(%)%*%?%[%]%$\"':<>|\\'{}])", char_to_hex)
  if not opts.keep_path_sep then
    url = url:gsub("/", char_to_hex)
  end

  -- Spaces in URLs are always safely encoded with `%20`, but not always safe
  -- with `+`. For example, `+` in a query param's value will be interpreted
  -- as a literal plus-sign if the decoder is using JavaScript's `decodeURI`
  -- function.
  url = url:gsub(" ", "%%20")
  return url
end

util.is_hex_color = function(s)
  return (s:match "^#%x%x%x$" or s:match "^#%x%x%x%x$" or s:match "^#%x%x%x%x%x%x$" or s:match "^#%x%x%x%x%x%x%x%x$")
    ~= nil
end

---Match the case of 'key' to the given 'prefix' of the key.
---
---@param prefix string
---@param key string
---@return string|?
util.match_case = function(prefix, key)
  local out_chars = {}
  for i = 1, string.len(key) do
    local c_key = string.sub(key, i, i)
    local c_pre = string.sub(prefix, i, i)
    if c_pre:lower() == c_key:lower() then
      table.insert(out_chars, c_pre)
    elseif c_pre:len() > 0 then
      return nil
    else
      table.insert(out_chars, c_key)
    end
  end
  return table.concat(out_chars, "")
end

---Check if a string is a checkbox list item
---
---Supported checboox lists:
--- - [ ] foo
--- - [x] foo
--- + [x] foo
--- * [ ] foo
--- 1. [ ] foo
--- 1) [ ] foo
---
---@param s string
---@return boolean
util.is_checkbox = function(s)
  -- - [ ] and * [ ] and + [ ]
  if string.match(s, "^%s*[-+*]%s+%[.%]") ~= nil then
    return true
  end
  -- 1. [ ] and 1) [ ]
  if string.match(s, "^%s*%d+[%.%)]%s+%[.%]") ~= nil then
    return true
  end
  return false
end

---Check if a string is a valid URL.
---@param s string
---@return boolean
util.is_url = function(s)
  local search = require "obsidian.search"

  if
    string.match(vim.trim(s), "^" .. search.Patterns[search.RefTypes.NakedUrl] .. "$")
    or string.match(vim.trim(s), "^" .. search.Patterns[search.RefTypes.FileUrl] .. "$")
    or string.match(vim.trim(s), "^" .. search.Patterns[search.RefTypes.MailtoUrl] .. "$")
  then
    return true
  else
    return false
  end
end

---Checks if a given string represents an image file based on its suffix.
---
---@param s string: The input string to check.
---@return boolean: Returns true if the string ends with a supported image suffix, false otherwise.
util.is_img = function(s)
  for _, suffix in ipairs { ".png", ".jpg", ".jpeg", ".heic", ".gif", ".svg", ".ico" } do
    if vim.endswith(s, suffix) then
      return true
    end
  end
  return false
end

-- This function removes a single backslash within double square brackets
util.unescape_single_backslash = function(text)
  return text:gsub("(%[%[[^\\]+)\\(%|[^\\]+]])", "%1%2")
end

util.string_enclosing_chars = { [["]], [[']] }

---Count the indentation of a line.
---@param str string
---@return integer
util.count_indent = function(str)
  local indent = 0
  for i = 1, #str do
    local c = string.sub(str, i, i)
    -- space or tab both count as 1 indent
    if c == " " or c == "	" then
      indent = indent + 1
    else
      break
    end
  end
  return indent
end

---Check if a string is only whitespace.
---@param str string
---@return boolean
util.is_whitespace = function(str)
  return string.match(str, "^%s+$") ~= nil
end

---Get the substring of `str` starting from the first character and up to the stop character,
---ignoring any enclosing characters (like double quotes) and stop characters that are within the
---enclosing characters. For example, if `str = [=["foo", "bar"]=]` and `stop_char = ","`, this
---would return the string `[=[foo]=]`.
---
---@param str string
---@param stop_chars string[]
---@param keep_stop_char boolean|?
---@return string|?, string
util.next_item = function(str, stop_chars, keep_stop_char)
  local og_str = str

  -- Check for enclosing characters.
  local enclosing_char = nil
  local first_char = string.sub(str, 1, 1)
  for _, c in ipairs(util.string_enclosing_chars) do
    if first_char == c then
      enclosing_char = c
      str = string.sub(str, 2)
      break
    end
  end

  local result
  local hits

  for _, stop_char in ipairs(stop_chars) do
    -- First check for next item when `stop_char` is present.
    if enclosing_char ~= nil then
      result, hits = string.gsub(
        str,
        "([^" .. enclosing_char .. "]+)([^\\]?)" .. enclosing_char .. "%s*" .. stop_char .. ".*",
        "%1%2"
      )
      result = enclosing_char .. result .. enclosing_char
    else
      result, hits = string.gsub(str, "([^" .. stop_char .. "]+)" .. stop_char .. ".*", "%1")
    end
    if hits ~= 0 then
      local i = string.find(str, stop_char, string.len(result), true)
      if keep_stop_char then
        return result .. stop_char, string.sub(str, i + 1)
      else
        return result, string.sub(str, i + 1)
      end
    end

    -- Now check for next item without the `stop_char` after.
    if not keep_stop_char and enclosing_char ~= nil then
      result, hits = string.gsub(str, "([^" .. enclosing_char .. "]+)([^\\]?)" .. enclosing_char .. "%s*$", "%1%2")
      result = enclosing_char .. result .. enclosing_char
    elseif not keep_stop_char then
      result = str
      hits = 1
    else
      result = nil
      hits = 0
    end
    if hits ~= 0 then
      if keep_stop_char then
        result = result .. stop_char
      end
      return result, ""
    end
  end

  return nil, og_str
end

---Strip whitespace from the right end of a string.
---@param str string
---@return string
util.rstrip_whitespace = function(str)
  str = string.gsub(str, "%s+$", "")
  return str
end

---Strip whitespace from the left end of a string.
---@param str string
---@param limit integer|?
---@return string
util.lstrip_whitespace = function(str, limit)
  if limit ~= nil then
    local num_found = 0
    while num_found < limit do
      str = string.gsub(str, "^%s", "")
      num_found = num_found + 1
    end
  else
    str = string.gsub(str, "^%s+", "")
  end
  return str
end

---Strip enclosing characters like quotes from a string.
---@param str string
---@return string
util.strip_enclosing_chars = function(str)
  local c_start = string.sub(str, 1, 1)
  local c_end = string.sub(str, #str, #str)
  for _, enclosing_char in ipairs(util.string_enclosing_chars) do
    if c_start == enclosing_char and c_end == enclosing_char then
      str = string.sub(str, 2, #str - 1)
      break
    end
  end
  return str
end

---Check if a string has enclosing characters like quotes.
---@param str string
---@return boolean
util.has_enclosing_chars = function(str)
  for _, enclosing_char in ipairs(util.string_enclosing_chars) do
    if vim.startswith(str, enclosing_char) and vim.endswith(str, enclosing_char) then
      return true
    end
  end
  return false
end

---Strip YAML comments from a string.
---@param str string
---@return string
util.strip_comments = function(str)
  if vim.startswith(str, "# ") then
    return ""
  elseif not util.has_enclosing_chars(str) then
    return select(1, string.gsub(str, [[%s+#%s.*$]], ""))
  else
    return str
  end
end

---Check if a string contains a substring.
---@param str string
---@param substr string
---@return boolean
util.string_contains = function(str, substr)
  local i = string.find(str, substr, 1, true)
  return i ~= nil
end

--------------------
--- Date helpers ---
--------------------

---Determines if the given date is a working day (not weekend)
---
---@param time integer
---
---@return boolean
util.is_working_day = function(time)
  local is_saturday = (os.date("%w", time) == "6")
  local is_sunday = (os.date("%w", time) == "0")
  return not (is_saturday or is_sunday)
end

--- Returns the previous day from given time
---
--- @param time integer
--- @return integer
util.previous_day = function(time)
  return time - (24 * 60 * 60)
end
---
--- Returns the next day from given time
---
--- @param time integer
--- @return integer
util.next_day = function(time)
  return time + (24 * 60 * 60)
end

---Determines the last working day before a given time
---
---@param time integer
---@return integer
util.working_day_before = function(time)
  local previous_day = util.previous_day(time)
  if util.is_working_day(previous_day) then
    return previous_day
  else
    return util.working_day_before(previous_day)
  end
end

---Determines the next working day before a given time
---
---@param time integer
---@return integer
util.working_day_after = function(time)
  local next_day = util.next_day(time)
  if util.is_working_day(next_day) then
    return next_day
  else
    return util.working_day_after(next_day)
  end
end

---@param link string
---@param opts { include_naked_urls: boolean|?, include_file_urls: boolean|?, include_block_ids: boolean|?, link_type: obsidian.search.RefTypes|? }|?
---
---@return string|?, string|?, obsidian.search.RefTypes|?
util.parse_link = function(link, opts)
  local search = require "obsidian.search"

  opts = opts and opts or {}

  local link_type = opts.link_type
  if link_type == nil then
    for match in
      vim.iter(search.find_refs(link, {
        include_naked_urls = opts.include_naked_urls,
        include_file_urls = opts.include_file_urls,
        include_block_ids = opts.include_block_ids,
      }))
    do
      local _, _, m_type = unpack(match)
      if m_type then
        link_type = m_type
        break
      end
    end
  end

  if link_type == nil then
    return nil
  end

  local link_location, link_name
  if link_type == search.RefTypes.Markdown then
    link_location = link:gsub("^%[(.-)%]%((.*)%)$", "%2")
    link_name = link:gsub("^%[(.-)%]%((.*)%)$", "%1")
  elseif link_type == search.RefTypes.NakedUrl then
    link_location = link
    link_name = link
  elseif link_type == search.RefTypes.FileUrl then
    link_location = link
    link_name = link
  elseif link_type == search.RefTypes.WikiWithAlias then
    link = util.unescape_single_backslash(link)
    -- remove boundary brackets, e.g. '[[XXX|YYY]]' -> 'XXX|YYY'
    link = link:sub(3, #link - 2)
    -- split on the "|"
    local split_idx = link:find "|"
    link_location = link:sub(1, split_idx - 1)
    link_name = link:sub(split_idx + 1)
  elseif link_type == search.RefTypes.Wiki then
    -- remove boundary brackets, e.g. '[[YYY]]' -> 'YYY'
    link = link:sub(3, #link - 2)
    link_location = link
    link_name = link
  elseif link_type == search.RefTypes.BlockID then
    link_location = util.standardize_block(link)
    link_name = link
  else
    error("not implemented for " .. link_type)
  end

  return link_location, link_name, link_type
end

------------------------------------
-- Miscellaneous helper functions --
------------------------------------
---@param anchor obsidian.note.HeaderAnchor
---@return string
util.format_anchor_label = function(anchor)
  return string.format(" ❯ %s", anchor.header)
end

-- We are very loose here because obsidian allows pretty much anything
util.ANCHOR_LINK_PATTERN = "#[%w%d\128-\255][^#]*"

util.BLOCK_PATTERN = "%^[%w%d][%w%d-]*"

util.BLOCK_LINK_PATTERN = "#" .. util.BLOCK_PATTERN

--- Strip anchor links from a line.
---@param line string
---@return string, string|?
util.strip_anchor_links = function(line)
  ---@type string|?
  local anchor

  while true do
    local anchor_match = string.match(line, util.ANCHOR_LINK_PATTERN .. "$")
    if anchor_match then
      anchor = anchor or ""
      anchor = anchor_match .. anchor
      line = string.sub(line, 1, -anchor_match:len() - 1)
    else
      break
    end
  end

  return line, anchor and util.standardize_anchor(anchor)
end

--- Parse a block line from a line.
---
---@param line string
---
---@return string|?
util.parse_block = function(line)
  local block_match = string.match(line, util.BLOCK_PATTERN .. "$")
  return block_match
end

--- Strip block links from a line.
---@param line string
---@return string, string|?
util.strip_block_links = function(line)
  local block_match = string.match(line, util.BLOCK_LINK_PATTERN .. "$")
  if block_match then
    line = string.sub(line, 1, -block_match:len() - 1)
  end
  return line, block_match
end

--- Standardize a block identifier.
---@param block_id string
---@return string
util.standardize_block = function(block_id)
  if vim.startswith(block_id, "#") then
    block_id = string.sub(block_id, 2)
  end

  if not vim.startswith(block_id, "^") then
    block_id = "^" .. block_id
  end

  return block_id
end

--- Check if a line is a markdown header.
---@param line string
---@return boolean
util.is_header = function(line)
  if string.match(line, "^#+%s+[%w]+") then
    return true
  else
    return false
  end
end

--- Get the header level of a line.
---@param line string
---@return integer
util.header_level = function(line)
  local headers, match_count = string.gsub(line, "^(#+)%s+[%w]+.*", "%1")
  if match_count > 0 then
    return string.len(headers)
  else
    return 0
  end
end

---@param line string
---@return { header: string, level: integer, anchor: string }|?
util.parse_header = function(line)
  local header_start, header = string.match(line, "^(#+)%s+([^%s]+.*)$")
  if header_start and header then
    header = vim.trim(header)
    return {
      header = vim.trim(header),
      level = string.len(header_start),
      anchor = util.header_to_anchor(header),
    }
  else
    return nil
  end
end

--- Standardize a header anchor link.
---
---@param anchor string
---
---@return string
util.standardize_anchor = function(anchor)
  -- Lowercase everything.
  anchor = string.lower(anchor)
  -- Replace whitespace with "-".
  anchor = string.gsub(anchor, "%s", "-")
  -- Remove every non-alphanumeric character.
  anchor = string.gsub(anchor, "[^#%w\128-\255_-]", "")
  return anchor
end

--- Transform a markdown header into an link, e.g. "# Hello World" -> "#hello-world".
---
---@param header string
---
---@return string
util.header_to_anchor = function(header)
  -- Remove leading '#' and strip whitespace.
  local anchor = vim.trim(string.gsub(header, [[^#+%s+]], ""))
  return util.standardize_anchor("#" .. anchor)
end

---@alias datetime_cadence "daily"

--- Parse possible relative date macros like '@tomorrow'.
---
---@param macro string
---
---@return { macro: string, offset: integer, cadence: datetime_cadence }[]
util.resolve_date_macro = function(macro)
  ---@type { macro: string, offset: integer, cadence: datetime_cadence }[]
  local out = {}
  for m, offset_days in pairs { today = 0, tomorrow = 1, yesterday = -1 } do
    m = "@" .. m
    if vim.startswith(m, macro) then
      out[#out + 1] = { macro = m, offset = offset_days, cadence = "daily" }
    end
  end
  return out
end

--- Check if a string contains invalid characters.
---
--- @param fname string
---
--- @return boolean
util.contains_invalid_characters = function(fname)
  local invalid_chars = "#^%[%]|"
  return string.find(fname, "[" .. invalid_chars .. "]") ~= nil
end

---Check if a string is NaN
---
---@param v any
---@return boolean
util.isNan = function(v)
  return tostring(v) == tostring(0 / 0)
end

---Higher order function, make sure a function is called with complete lines
---@param fn fun(string)?
---@return fun(string)
util.buffer_fn = function(fn)
  if not fn then
    return function() end
  end
  local buffer = ""
  return function(data)
    buffer = buffer .. data
    local lines = vim.split(buffer, "\n")
    if #lines > 1 then
      for i = 1, #lines - 1 do
        fn(lines[i])
      end
      buffer = lines[#lines] -- Store remaining partial line
    end
  end
end

---@param event string
---@param callback fun(...)
---@param ... any
---@return boolean success
util.fire_callback = function(event, callback, ...)
  local log = require "obsidian.log"
  if not callback then
    return false
  end
  local ok, err = pcall(callback, ...)
  if ok then
    return true
  else
    log.error("Error running %s callback: %s", event, err)
    return false
  end
end

---@param node_type string | string[]
---@return boolean
util.in_node = function(node_type)
  local function in_node(t)
    local node = ts.get_node()
    while node do
      if node:type() == t then
        return true
      end
      node = node:parent()
    end
    return false
  end
  if type(node_type) == "string" then
    return in_node(node_type)
  elseif type(node_type) == "table" then
    for _, t in ipairs(node_type) do
      local is_in_node = in_node(t)
      if is_in_node then
        return true
      end
    end
  end
  return false
end

return util

local abc = require "obsidian.abc"
local completion = require "obsidian.completion.refs"
local LinkStyle = require("obsidian.config").LinkStyle
local obsidian = require "obsidian"
local util = require "obsidian.util"
local api = require "obsidian.api"
local search = require "obsidian.search"
local iter = vim.iter

---Used to track variables that are used between reusable method calls. This is required, because each
---call to the sources's completion hook won't create a new source object, but will reuse the same one.
---@class obsidian.completion.sources.base.RefsSourceCompletionContext : obsidian.ABC
---@field client obsidian.Client
---@field completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback
---@field request obsidian.completion.sources.base.Request
---@field in_buffer_only boolean
---@field search string|?
---@field insert_start integer|?
---@field insert_end integer|?
---@field ref_type obsidian.completion.RefType|?
---@field block_link string|?
---@field anchor_link string|?
---@field new_text_to_option table<string, obsidian.completion.sources.blink.CompletionItem>
local RefsSourceCompletionContext = abc.new_class()

RefsSourceCompletionContext.new = function()
  return RefsSourceCompletionContext.init()
end

---@class obsidian.completion.sources.base.RefsSourceBase : obsidian.ABC
---@field incomplete_response table
---@field complete_response table
local RefsSourceBase = abc.new_class()

---@return obsidian.completion.sources.base.RefsSourceBase
RefsSourceBase.new = function()
  return RefsSourceBase.init()
end

RefsSourceBase.get_trigger_characters = completion.get_trigger_characters

---Sets up a new completion context that is used to pass around variables between completion source methods
---@param completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback
---@param request obsidian.completion.sources.base.Request
---@return obsidian.completion.sources.base.RefsSourceCompletionContext
function RefsSourceBase:new_completion_context(completion_resolve_callback, request)
  local completion_context = RefsSourceCompletionContext.new()

  -- Sets up the completion callback, which will be called when the (possibly incomplete) completion items are ready
  completion_context.completion_resolve_callback = completion_resolve_callback

  -- This request object will be used to determine the current cursor location and the text around it
  completion_context.request = request

  completion_context.client = assert(obsidian.get_client())

  completion_context.in_buffer_only = false

  return completion_context
end

--- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods
---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext
function RefsSourceBase:process_completion(cc)
  if not self:can_complete_request(cc) then
    return
  end

  self:strip_links(cc)
  self:determine_buffer_only_search_scope(cc)

  if cc.in_buffer_only then
    local note = api.current_note(0, { collect_anchor_links = true, collect_blocks = true })
    if note then
      self:process_search_results(cc, { note })
    else
      cc.completion_resolve_callback(self.incomplete_response)
    end
  else
    local search_ops = cc.client.search_defaults()
    search_ops.ignore_case = true

    search.find_notes_async(cc.search, function(results)
      self:process_search_results(cc, results)
    end, {
      search = search_ops,
      notes = { collect_anchor_links = cc.anchor_link ~= nil, collect_blocks = cc.block_link ~= nil },
    })
  end
end

--- Returns whatever it's possible to complete the search and sets up the search related variables in cc
---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext
---@return boolean success provides a chance to return early if the request didn't meet the requirements
function RefsSourceBase:can_complete_request(cc)
  local can_complete
  can_complete, cc.search, cc.insert_start, cc.insert_end, cc.ref_type = completion.can_complete(cc.request)

  if not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then
    cc.completion_resolve_callback(self.incomplete_response)
    return false
  end

  return true
end

---Collect matching block links.
---@param note obsidian.Note
---@param block_link string?
---@return obsidian.note.Block[]|?
function RefsSourceBase:collect_matching_blocks(note, block_link)
  ---@type obsidian.note.Block[]|?
  local matching_blocks
  if block_link then
    assert(note.blocks)
    matching_blocks = {}
    for block_id, block_data in pairs(note.blocks) do
      if vim.startswith("#" .. block_id, block_link) then
        table.insert(matching_blocks, block_data)
      end
    end

    if #matching_blocks == 0 then
      -- Unmatched, create a mock one.
      table.insert(matching_blocks, { id = util.standardize_block(block_link), line = 1 })
    end
  end

  return matching_blocks
end

---Collect matching anchor links.
---@param note obsidian.Note
---@param anchor_link string?
---@return obsidian.note.HeaderAnchor[]?
function RefsSourceBase:collect_matching_anchors(note, anchor_link)
  ---@type obsidian.note.HeaderAnchor[]|?
  local matching_anchors
  if anchor_link then
    assert(note.anchor_links)
    matching_anchors = {}
    for anchor, anchor_data in pairs(note.anchor_links) do
      if vim.startswith(anchor, anchor_link) then
        table.insert(matching_anchors, anchor_data)
      end
    end

    if #matching_anchors == 0 then
      -- Unmatched, create a mock one.
      table.insert(matching_anchors, { anchor = anchor_link, header = string.sub(anchor_link, 2), level = 1, line = 1 })
    end
  end

  return matching_anchors
end

--- Strips block and anchor links from the current search string
---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext
function RefsSourceBase:strip_links(cc)
  cc.search, cc.block_link = util.strip_block_links(cc.search)
  cc.search, cc.anchor_link = util.strip_anchor_links(cc.search)

  -- If block link is incomplete, we'll match against all block links.
  if not cc.block_link and vim.endswith(cc.search, "#^") then
    cc.block_link = "#^"
    cc.search = string.sub(cc.search, 1, -3)
  end

  -- If anchor link is incomplete, we'll match against all anchor links.
  if not cc.anchor_link and vim.endswith(cc.search, "#") then
    cc.anchor_link = "#"
    cc.search = string.sub(cc.search, 1, -2)
  end
end

--- Determines whatever the in_buffer_only should be enabled
---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext
function RefsSourceBase:determine_buffer_only_search_scope(cc)
  if (cc.anchor_link or cc.block_link) and string.len(cc.search) == 0 then
    -- Search over headers/blocks in current buffer only.
    cc.in_buffer_only = true
  end
end

---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext
---@param results obsidian.Note[]
function RefsSourceBase:process_search_results(cc, results)
  assert(cc)
  assert(results)

  local completion_items = {}

  cc.new_text_to_option = {}

  for note in iter(results) do
    ---@cast note obsidian.Note

    local matching_blocks = self:collect_matching_blocks(note, cc.block_link)
    local matching_anchors = self:collect_matching_anchors(note, cc.anchor_link)

    if cc.in_buffer_only then
      self:update_completion_options(cc, nil, nil, matching_anchors, matching_blocks, note)
    else
      -- Collect all valid aliases for the note, including ID, title, and filename.
      ---@type string[]
      local aliases
      if not cc.in_buffer_only then
        aliases = util.tbl_unique { tostring(note.id), note:display_name(), unpack(note.aliases) }
        if note.title ~= nil then
          table.insert(aliases, note.title)
        end
      end

      for alias in iter(aliases) do
        self:update_completion_options(cc, alias, nil, matching_anchors, matching_blocks, note)
        local alias_case_matched = util.match_case(cc.search, alias)

        if
          alias_case_matched ~= nil
          and alias_case_matched ~= alias
          and not vim.list_contains(note.aliases, alias_case_matched)
          and Obsidian.opts.completion.match_case
        then
          self:update_completion_options(cc, alias_case_matched, nil, matching_anchors, matching_blocks, note)
        end
      end

      if note.alt_alias ~= nil then
        self:update_completion_options(cc, note:display_name(), note.alt_alias, matching_anchors, matching_blocks, note)
      end
    end
  end

  for _, option in pairs(cc.new_text_to_option) do
    -- TODO: need a better label, maybe just the note's display name?
    ---@type string
    local label
    if cc.ref_type == completion.RefType.Wiki then
      label = string.format("[[%s]]", option.label)
    elseif cc.ref_type == completion.RefType.Markdown then
      label = string.format("[%s](…)", option.label)
    else
      error "not implemented"
    end

    table.insert(completion_items, {
      documentation = option.documentation,
      sortText = option.sort_text,
      label = label,
      kind = vim.lsp.protocol.CompletionItemKind.Reference,
      textEdit = {
        newText = option.new_text,
        range = {
          ["start"] = {
            line = cc.request.context.cursor.row - 1,
            character = cc.insert_start,
          },
          ["end"] = {
            line = cc.request.context.cursor.row - 1,
            character = cc.insert_end + 1,
          },
        },
      },
    })
  end

  cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = completion_items }))
end

---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext
---@param label string|?
---@param alt_label string|?
---@param note obsidian.Note
function RefsSourceBase:update_completion_options(cc, label, alt_label, matching_anchors, matching_blocks, note)
  ---@type { label: string|?, alt_label: string|?, anchor: obsidian.note.HeaderAnchor|?, block: obsidian.note.Block|? }[]
  local new_options = {}
  if matching_anchors ~= nil then
    for anchor in iter(matching_anchors) do
      table.insert(new_options, { label = label, alt_label = alt_label, anchor = anchor })
    end
  elseif matching_blocks ~= nil then
    for block in iter(matching_blocks) do
      table.insert(new_options, { label = label, alt_label = alt_label, block = block })
    end
  else
    if label then
      table.insert(new_options, { label = label, alt_label = alt_label })
    end

    -- Add all blocks and anchors, let cmp sort it out.
    for _, anchor_data in pairs(note.anchor_links or {}) do
      table.insert(new_options, { label = label, alt_label = alt_label, anchor = anchor_data })
    end
    for _, block_data in pairs(note.blocks or {}) do
      table.insert(new_options, { label = label, alt_label = alt_label, block = block_data })
    end
  end

  -- De-duplicate options relative to their `new_text`.
  for _, option in ipairs(new_options) do
    ---@type obsidian.config.LinkStyle
    local link_style
    if cc.ref_type == completion.RefType.Wiki then
      link_style = LinkStyle.wiki
    elseif cc.ref_type == completion.RefType.Markdown then
      link_style = LinkStyle.markdown
    else
      error "not implemented"
    end

    ---@type string, string, string, table|?
    local final_label, sort_text, new_text, documentation
    if option.label then
      new_text = api.format_link(
        note,
        { label = option.label, link_style = link_style, anchor = option.anchor, block = option.block }
      )

      final_label = assert(option.alt_label or option.label)
      if option.anchor then
        final_label = final_label .. option.anchor.anchor
      elseif option.block then
        final_label = final_label .. "#" .. option.block.id
      end
      sort_text = final_label

      documentation = {
        kind = "markdown",
        value = note:display_info {
          label = new_text,
          anchor = option.anchor,
          block = option.block,
        },
      }
    elseif option.anchor then
      -- In buffer anchor link.
      -- TODO: allow users to customize this?
      if cc.ref_type == completion.RefType.Wiki then
        new_text = "[[#" .. option.anchor.header .. "]]"
      elseif cc.ref_type == completion.RefType.Markdown then
        new_text = "[#" .. option.anchor.header .. "](" .. option.anchor.anchor .. ")"
      else
        error "not implemented"
      end

      final_label = option.anchor.anchor
      sort_text = final_label

      documentation = {
        kind = "markdown",
        value = string.format("`%s`", new_text),
      }
    elseif option.block then
      -- In buffer block link.
      -- TODO: allow users to customize this?
      if cc.ref_type == completion.RefType.Wiki then
        new_text = "[[#" .. option.block.id .. "]]"
      elseif cc.ref_type == completion.RefType.Markdown then
        new_text = "[#" .. option.block.id .. "](#" .. option.block.id .. ")"
      else
        error "not implemented"
      end

      final_label = "#" .. option.block.id
      sort_text = final_label

      documentation = {
        kind = "markdown",
        value = string.format("`%s`", new_text),
      }
    else
      error "should not happen"
    end

    if cc.new_text_to_option[new_text] then
      cc.new_text_to_option[new_text].sort_text = cc.new_text_to_option[new_text].sort_text .. " " .. sort_text
    else
      cc.new_text_to_option[new_text] =
        { label = final_label, new_text = new_text, sort_text = sort_text, documentation = documentation }
    end
  end
end

return RefsSourceBase

local Line = require "obsidian.yaml.line"
local abc = require "obsidian.abc"
local util = require "obsidian.util"
local iter = vim.iter

local m = {}

---@class obsidian.yaml.ParserOpts
---@field luanil boolean
local ParserOpts = {}

m.ParserOpts = ParserOpts

---@return obsidian.yaml.ParserOpts
ParserOpts.default = function()
  return {
    luanil = true,
  }
end

---@param opts table
---@return obsidian.yaml.ParserOpts
ParserOpts.normalize = function(opts)
  ---@type obsidian.yaml.ParserOpts
  opts = vim.tbl_extend("force", ParserOpts.default(), opts)
  return opts
end

---@class obsidian.yaml.Parser : obsidian.ABC
---@field opts obsidian.yaml.ParserOpts
local Parser = abc.new_class()

m.Parser = Parser

---@enum YamlType
local YamlType = {
  Scalar = "Scalar", -- a boolean, string, number, or NULL
  Mapping = "Mapping",
  Array = "Array",
  ArrayItem = "ArrayItem",
  EmptyLine = "EmptyLine",
}

m.YamlType = YamlType

---@class vim.NIL

---Create a new Parser.
---@param opts obsidian.yaml.ParserOpts|?
---@return obsidian.yaml.Parser
m.new = function(opts)
  local self = Parser.init()
  self.opts = ParserOpts.normalize(opts and opts or {})
  return self
end

---Parse a YAML string.
---@param str string
---@return any
Parser.parse = function(self, str)
  -- Collect and pre-process lines.
  local lines = {}
  local base_indent = 0
  for raw_line in str:gmatch "[^\r\n]+" do
    local ok, result = pcall(Line.new, raw_line, base_indent)
    if ok then
      local line = result
      if #lines == 0 then
        base_indent = line.indent
        line.indent = 0
      end
      table.insert(lines, line)
    else
      local err = result
      error(self:_error_msg(tostring(err), #lines + 1))
    end
  end

  -- Now iterate over the root elements, differing to `self:_parse_next()` to recurse into child elements.
  ---@type any
  local root_value = nil
  ---@type table|?
  local parent = nil
  local current_indent = 0
  local i = 1
  while i <= #lines do
    local line = lines[i]

    if line:is_empty() then
      -- Empty line, skip it.
      i = i + 1
    elseif line.indent == current_indent then
      local value
      local value_type
      i, value, value_type = self:_parse_next(lines, i)
      assert(value_type ~= YamlType.EmptyLine)
      if root_value == nil and line.indent == 0 then
        -- Set the root value.
        if value_type == YamlType.ArrayItem then
          root_value = { value }
        else
          root_value = value
        end

        -- The parent must always be a table (array or mapping), so set that to the root value now
        -- if we have a table.
        if type(root_value) == "table" then
          parent = root_value
        end
      elseif util.islist(parent) and value_type == YamlType.ArrayItem then
        -- Add value to parent array.
        parent[#parent + 1] = value
      elseif type(parent) == "table" and value_type == YamlType.Mapping then
        assert(parent ~= nil) -- for type checking
        -- Add value to parent mapping.
        for key, item in pairs(value) do
          -- Check for duplicate keys.
          if parent[key] ~= nil then
            error(self:_error_msg("duplicate key '" .. key .. "' found in table", i, line.content))
          else
            parent[key] = item
          end
        end
      else
        error(self:_error_msg("unexpected value", i, line.content))
      end
    else
      error(self:_error_msg("invalid indentation", i))
    end
    current_indent = line.indent
  end

  return root_value
end

---Parse the next single item, recursing to child blocks if necessary.
---@param self obsidian.yaml.Parser
---@param lines obsidian.yaml.Line[]
---@param i integer
---@param text string|?
---@return integer, any, string
Parser._parse_next = function(self, lines, i, text)
  local line = lines[i]
  if text == nil then
    -- Skip empty lines.
    while line:is_empty() and i <= #lines do
      i = i + 1
      line = lines[i]
    end
    if line:is_empty() then
      return i, nil, YamlType.EmptyLine
    end
    text = util.strip_comments(line.content)
  end

  local _, ok, value

  -- First just check for a string enclosed in quotes.
  if util.has_enclosing_chars(text) then
    _, _, value = self:_parse_string(i, text)
    return i + 1, value, YamlType.Scalar
  end

  -- Check for array item, like `- foo`.
  ok, i, value = self:_try_parse_array_item(lines, i, text)
  if ok then
    return i, value, YamlType.ArrayItem
  end

  -- Check for a block string field, like `foo: |`.
  ok, i, value = self:_try_parse_block_string(lines, i, text)
  if ok then
    return i, value, YamlType.Mapping
  end

  -- Check for any other `key: value` fields.
  ok, i, value = self:_try_parse_field(lines, i, text)
  if ok then
    return i, value, YamlType.Mapping
  end

  -- Otherwise we have an inline value.
  local value_type
  value, value_type = self:_parse_inline_value(i, text)
  return i + 1, value, value_type
end

---@return vim.NIL|nil
Parser._new_null = function(self)
  if self.opts.luanil then
    return nil
  else
    return vim.NIL
  end
end

---@param self obsidian.yaml.Parser
---@param msg string
---@param line_num integer
---@param line_text string|?
---@return string
---@diagnostic disable-next-line: unused-local
Parser._error_msg = function(self, msg, line_num, line_text)
  local full_msg = "[line=" .. tostring(line_num) .. "] " .. msg
  if line_text ~= nil then
    full_msg = full_msg .. " (text='" .. line_text .. "')"
  end
  return full_msg
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param lines obsidian.yaml.Line[]
---@param text string|?
---@return boolean, integer, any
Parser._try_parse_field = function(self, lines, i, text)
  local line = lines[i]
  text = text and text or util.strip_comments(line.content)

  local _, key, value

  -- First look for start of mapping, array, block, etc, e.g. 'foo:'
  _, _, key = string.find(text, "([a-zA-Z0-9_-]+[a-zA-Z0-9_ -]*):$")
  if not key then
    -- Then try inline field, e.g. 'foo: bar'
    _, _, key, value = string.find(text, "([a-zA-Z0-9_-]+[a-zA-Z0-9_ -]*): (.*)")
  end

  value = value and vim.trim(value) or nil
  if value == "" then
    value = nil
  end

  if key ~= nil and value ~= nil then
    -- This is a mapping, e.g. `foo: 1`.
    local out = {}
    value = self:_parse_inline_value(i, value)
    local j = i + 1
    -- Check for multi-line string here.
    local next_line = lines[j]
    if type(value) == "string" and next_line ~= nil and next_line.indent > line.indent then
      local next_indent = next_line.indent
      while next_line ~= nil and next_line.indent == next_indent do
        local next_value_str = util.strip_comments(next_line.content)
        if string.len(next_value_str) > 0 then
          local next_value = self:_parse_inline_value(j, next_line.content)
          if type(next_value) ~= "string" then
            error(self:_error_msg("expected a string, found " .. type(next_value), j, next_line.content))
          end
          value = value .. " " .. next_value
        end
        j = j + 1
        next_line = lines[j]
      end
    end
    out[key] = value
    return true, j, out
  elseif key ~= nil then
    local out = {}
    local next_line = lines[i + 1]
    local j = i + 1
    if next_line ~= nil and next_line.indent >= line.indent and vim.startswith(next_line.content, "- ") then
      -- This is the start of an array.
      local array
      j, array = self:_parse_array(lines, j)
      out[key] = array
    elseif next_line ~= nil and next_line.indent > line.indent then
      -- This is the start of a mapping.
      local mapping
      j, mapping = self:_parse_mapping(j, lines)
      out[key] = mapping
    else
      -- This is an implicit null field.
      out[key] = self:_new_null()
    end
    return true, j, out
  else
    return false, i, nil
  end
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param lines obsidian.yaml.Line[]
---@param text string|?
---@return boolean, integer, any
Parser._try_parse_block_string = function(self, lines, i, text)
  local line = lines[i]
  text = text and text or util.strip_comments(line.content)
  local _, _, block_key = string.find(text, "([a-zA-Z0-9_-]+):%s?|")
  if block_key ~= nil then
    local block_lines = {}
    local j = i + 1
    local next_line = lines[j]
    if next_line == nil then
      error(self:_error_msg("expected another line", i, text))
    end
    local item_indent = next_line.indent
    while j <= #lines do
      next_line = lines[j]
      if next_line ~= nil and next_line.indent >= item_indent then
        j = j + 1
        table.insert(block_lines, util.lstrip_whitespace(next_line.raw_content, item_indent))
      else
        break
      end
    end
    local out = {}
    out[block_key] = table.concat(block_lines, "\n")
    return true, j, out
  else
    return false, i, nil
  end
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param lines obsidian.yaml.Line[]
---@param text string|?
---@return boolean, integer, any
Parser._try_parse_array_item = function(self, lines, i, text)
  local line = lines[i]
  text = text and text or util.strip_comments(line.content)
  if vim.startswith(text, "- ") then
    local _, _, array_item_str = string.find(text, "- (.*)")
    local value
    -- Check for null entry.
    if array_item_str == "" then
      value = self:_new_null()
      i = i + 1
    else
      i, value = self:_parse_next(lines, i, array_item_str)
    end
    return true, i, value
  else
    return false, i, nil
  end
end

---@param self obsidian.yaml.Parser
---@param lines obsidian.yaml.Line[]
---@param i integer
---@return integer, any[]
Parser._parse_array = function(self, lines, i)
  local out = {}
  local item_indent = lines[i].indent
  while i <= #lines do
    local line = lines[i]
    if line.indent == item_indent and vim.startswith(line.content, "- ") then
      local is_array_item, value
      is_array_item, i, value = self:_try_parse_array_item(lines, i)
      assert(is_array_item)
      out[#out + 1] = value
    elseif line:is_empty() then
      i = i + 1
    else
      break
    end
  end
  if vim.tbl_isempty(out) then
    error(self:_error_msg("tried to parse an array but didn't find any entries", i))
  end
  return i, out
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param lines obsidian.yaml.Line[]
---@return integer, table
Parser._parse_mapping = function(self, i, lines)
  local out = {}
  local item_indent = lines[i].indent
  while i <= #lines do
    local line = lines[i]
    if line.indent == item_indent then
      local value, value_type
      i, value, value_type = self:_parse_next(lines, i)
      if value_type == YamlType.Mapping then
        for key, item in pairs(value) do
          -- Check for duplicate keys.
          if out[key] ~= nil then
            error(self:_error_msg("duplicate key '" .. key .. "' found in table", i))
          else
            out[key] = item
          end
        end
      else
        error(self:_error_msg("unexpected value found found in table", i))
      end
    else
      break
    end
  end
  if vim.tbl_isempty(out) then
    error(self:_error_msg("tried to parse a mapping but didn't find any entries to parse", i))
  end
  return i, out
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param text string
---@return any, string
Parser._parse_inline_value = function(self, i, text)
  for parse_func_and_type in iter {
    { self._parse_number, YamlType.Scalar },
    { self._parse_null, YamlType.Scalar },
    { self._parse_boolean, YamlType.Scalar },
    { self._parse_inline_array, YamlType.Array },
    { self._parse_inline_mapping, YamlType.Mapping },
    { self._parse_string, YamlType.Scalar },
  } do
    local parse_func, parse_type = unpack(parse_func_and_type)
    local ok, errmsg, res = parse_func(self, i, text)
    if ok then
      return res, parse_type
    elseif errmsg ~= nil then
      error(errmsg)
    end
  end
  -- Should never get here because we always fall back to parsing as a string.
  error(self:_error_msg("unable to parse", i))
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param text string
---@return boolean, string|?, any[]|?
Parser._parse_inline_array = function(self, i, text)
  local str
  if vim.startswith(text, "[") then
    str = string.sub(text, 2)
  else
    return false, nil, nil
  end

  if vim.endswith(str, "]") then
    str = string.sub(str, 1, -2)
  else
    return false, nil, nil
  end

  local out = {}
  while string.len(str) > 0 do
    local item_str
    if vim.startswith(str, "[") then
      -- Nested inline array.
      item_str, str = util.next_item(str, { "]" }, true)
    elseif vim.startswith(str, "{") then
      -- Nested inline mapping.
      item_str, str = util.next_item(str, { "}" }, true)
    else
      -- Regular item.
      item_str, str = util.next_item(str, { "," }, false)
    end
    if item_str == nil then
      return false, self:_error_msg("invalid inline array", i, text), nil
    end
    out[#out + 1] = self:_parse_inline_value(i, item_str)

    if vim.startswith(str, ",") then
      str = string.sub(str, 2)
    end
    str = util.lstrip_whitespace(str)
  end

  return true, nil, out
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param text string
---@return boolean, string|?, table|?
Parser._parse_inline_mapping = function(self, i, text)
  local str
  if vim.startswith(text, "{") then
    str = string.sub(text, 2)
  else
    return false, nil, nil
  end

  if vim.endswith(str, "}") then
    str = string.sub(str, 1, -2)
  else
    return false, self:_error_msg("invalid inline mapping", i, text), nil
  end

  local out = {}
  while string.len(str) > 0 do
    -- Parse the key.
    local key_str
    key_str, str = util.next_item(str, { ":" }, false)
    if key_str == nil then
      return false, self:_error_msg("invalid inline mapping", i, text), nil
    end
    local _, _, key = self:_parse_string(i, key_str)

    -- Parse the value.
    str = util.lstrip_whitespace(str)
    local value_str
    if vim.startswith(str, "[") then
      -- Nested inline array.
      value_str, str = util.next_item(str, { "]" }, true)
    elseif vim.startswith(str, "{") then
      -- Nested inline mapping.
      value_str, str = util.next_item(str, { "}" }, true)
    else
      -- Regular item.
      value_str, str = util.next_item(str, { "," }, false)
    end
    if value_str == nil then
      return false, self:_error_msg("invalid inline mapping", i, text), nil
    end
    local value = self:_parse_inline_value(i, value_str)
    if out[key] == nil then
      out[key] = value
    else
      return false, self:_error_msg("duplicate key '" .. key .. "' found in inline mapping", i, text), nil
    end

    if vim.startswith(str, ",") then
      str = util.lstrip_whitespace(string.sub(str, 2))
    end
  end

  return true, nil, out
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param text string
---@return boolean, string|?, string
---@diagnostic disable-next-line: unused-local
Parser._parse_string = function(self, i, text)
  if vim.startswith(text, [["]]) and vim.endswith(text, [["]]) then
    -- when the text is enclosed with double-quotes we need to un-escape certain characters.
    text = string.gsub(text, vim.pesc [[\"]], [["]])
  end
  return true, nil, util.strip_enclosing_chars(vim.trim(text))
end

---Parse a string value.
---@param self obsidian.yaml.Parser
---@param text string
---@return string
Parser.parse_string = function(self, text)
  local _, _, str = self:_parse_string(1, util.strip_comments(text))
  return str
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param text string
---@return boolean, string|?, number|?
---@diagnostic disable-next-line: unused-local
Parser._parse_number = function(self, i, text)
  local out = tonumber(text)
  if out == nil or util.isNan(out) then
    return false, nil, nil
  else
    return true, nil, out
  end
end

---Parse a number value.
---@param self obsidian.yaml.Parser
---@param text string
---@return number
Parser.parse_number = function(self, text)
  local ok, errmsg, res = self:_parse_number(1, vim.trim(util.strip_comments(text)))
  if not ok then
    errmsg = errmsg and errmsg or self:_error_msg("failed to parse a number", 1, text)
    error(errmsg)
  else
    assert(res ~= nil)
    return res
  end
end

---@param self obsidian.yaml.Parser
---@param i integer
---@param text string
---@return boolean, string|?, boolean|?
---@diagnostic disable-next-line: unused-local
Parser._parse_boolean = function(self, i, text)
  if text == "true" then
    return true, nil, true
  elseif text == "false" then
    return true, nil, false
  else
    return false, nil, nil
  end
end

---Parse a boolean value.
---@param self obsidian.yaml.Parser
---@param text string
---@return boolean
Parser.parse_boolean = function(self, text)
  local ok, errmsg, res = self:_parse_boolean(1, vim.trim(util.strip_comments(text)))
  if not ok then
    errmsg = errmsg and errmsg or self:_error_msg("failed to parse a boolean", 1, text)
    error(errmsg)
  else
    assert(res ~= nil)
    return res
  end
end

---@param self obsidian.yaml.Parser
---@param text string
---@return boolean, string|?, vim.NIL|nil
---@diagnostic disable-next-line: unused-local
Parser._parse_null = function(self, i, text)
  if text == "null" or text == "" then
    return true, nil, self:_new_null()
  else
    return false, nil, nil
  end
end

---Parse a NULL value.
---@param self obsidian.yaml.Parser
---@param text string
---@return vim.NIL|nil
Parser.parse_null = function(self, text)
  local ok, errmsg, res = self:_parse_null(1, vim.trim(util.strip_comments(text)))
  if not ok then
    errmsg = errmsg and errmsg or self:_error_msg("failed to parse a null value", 1, text)
    error(errmsg)
  else
    return res
  end
end

---Deserialize a YAML string.
m.loads = function(str)
  local parser = m.new()
  return parser:parse(str)
end

return m

local log = require "obsidian.log"
local Workspace = require "obsidian.workspace"

---@param data CommandArgs
return function(_, data)
  if not data.args or string.len(data.args) == 0 then
    local picker = Obsidian.picker
    if not picker then
      log.info("Current workspace: '%s' @ '%s'", Obsidian.workspace.name, Obsidian.workspace.path)
      return
    end

    local options = {}
    for i, spec in ipairs(Obsidian.opts.workspaces) do
      local workspace = Workspace.new_from_spec(spec)
      if workspace == Obsidian.workspace then
        options[#options + 1] = string.format("*[%d] %s @ '%s'", i, workspace.name, workspace.path)
      else
        options[#options + 1] = string.format("[%d] %s @ '%s'", i, workspace.name, workspace.path)
      end
    end

    picker:pick(options, {
      prompt_title = "Workspaces",
      callback = function(workspace_str)
        local idx = tonumber(string.match(workspace_str, "%*?%[(%d+)]"))
        Workspace.switch(Obsidian.opts.workspaces[idx].name, { lock = true })
      end,
    })
  else
    Workspace.switch(data.args, { lock = true })
  end
end

--- *obsidian-api*
---
--- The Obsidian.nvim Lua API.
---
--- ==============================================================================
---
--- Table of contents
---
---@toc

local Path = require "obsidian.path"
local async = require "plenary.async"
local channel = require("plenary.async.control").channel
local Note = require "obsidian.note"
local log = require "obsidian.log"
local util = require "obsidian.util"
local search = require "obsidian.search"
local AsyncExecutor = require("obsidian.async").AsyncExecutor
local block_on = require("obsidian.async").block_on
local iter = vim.iter

---@class obsidian.SearchOpts
---
---@field sort boolean|?
---@field include_templates boolean|?
---@field ignore_case boolean|?
---@field default function?

--- The Obsidian client is the main API for programmatically interacting with obsidian.nvim's features
--- in Lua. To get the client instance, run:
---
--- `local client = require("obsidian").get_client()`
---
---@toc_entry obsidian.Client
---
---@class obsidian.Client : obsidian.ABC
local Client = {}

local depreacted_lookup = {
  dir = "dir",
  buf_dir = "buf_dir",
  current_workspace = "workspace",
  opts = "opts",
}

Client.__index = function(_, k)
  if depreacted_lookup[k] then
    local msg = string.format(
      [[client.%s is depreacted, use Obsidian.%s instead.
client is going to be removed in the future as well.]],
      k,
      depreacted_lookup[k]
    )
    log.warn(msg)
    return Obsidian[depreacted_lookup[k]]
  elseif rawget(Client, k) then
    return rawget(Client, k)
  end
end

--- Create a new Obsidian client without additional setup.
--- This is mostly used for testing. In practice you usually want to obtain the existing
--- client through:
---
--- `require("obsidian").get_client()`
---
---@return obsidian.Client
Client.new = function()
  return setmetatable({}, Client)
end

--- Get the default search options.
---
---@return obsidian.SearchOpts
Client.search_defaults = function()
  return {
    sort = false,
    include_templates = false,
    ignore_case = false,
  }
end

---@param opts obsidian.SearchOpts|boolean|?
---
---@return obsidian.SearchOpts
---
---@private
Client._search_opts_from_arg = function(self, opts)
  if opts == nil then
    opts = self:search_defaults()
  elseif type(opts) == "boolean" then
    local sort = opts
    opts = self:search_defaults()
    opts.sort = sort
  end
  return opts
end

---@param opts obsidian.SearchOpts|boolean|?
---@param additional_opts obsidian.search.SearchOpts|?
---
---@return obsidian.search.SearchOpts
---
---@private
Client._prepare_search_opts = function(self, opts, additional_opts)
  opts = self:_search_opts_from_arg(opts)

  local search_opts = {}

  if opts.sort then
    search_opts.sort_by = Obsidian.opts.sort_by
    search_opts.sort_reversed = Obsidian.opts.sort_reversed
  end

  if not opts.include_templates and Obsidian.opts.templates ~= nil and Obsidian.opts.templates.folder ~= nil then
    search.SearchOpts.add_exclude(search_opts, tostring(Obsidian.opts.templates.folder))
  end

  if opts.ignore_case then
    search_opts.ignore_case = true
  end

  if additional_opts ~= nil then
    search_opts = search.SearchOpts.merge(search_opts, additional_opts)
  end

  return search_opts
end

---@param term string
---@param search_opts obsidian.SearchOpts|boolean|?
---@param find_opts obsidian.SearchOpts|boolean|?
---
---@return function
---
---@private
Client._search_iter_async = function(self, term, search_opts, find_opts)
  local tx, rx = channel.mpsc()
  local found = {}

  local function on_exit(_)
    tx.send(nil)
  end

  ---@param content_match MatchData
  local function on_search_match(content_match)
    local path = Path.new(content_match.path.text):resolve { strict = true }
    if not found[path.filename] then
      found[path.filename] = true
      tx.send(path)
    end
  end

  ---@param path_match string
  local function on_find_match(path_match)
    local path = Path.new(path_match):resolve { strict = true }
    if not found[path.filename] then
      found[path.filename] = true
      tx.send(path)
    end
  end

  local cmds_done = 0 -- out of the two, one for 'search' and one for 'find'

  search.search_async(
    Obsidian.dir,
    term,
    self:_prepare_search_opts(search_opts, { fixed_strings = true, max_count_per_file = 1 }),
    on_search_match,
    on_exit
  )

  search.find_async(
    Obsidian.dir,
    term,
    self:_prepare_search_opts(find_opts, { ignore_case = true }),
    on_find_match,
    on_exit
  )

  return function()
    while cmds_done < 2 do
      local value = rx.recv()
      if value == nil then
        cmds_done = cmds_done + 1
      else
        return value
      end
    end
    return nil
  end
end

---@class obsidian.TagLocation
---
---@field tag string The tag found.
---@field note obsidian.Note The note instance where the tag was found.
---@field path string|obsidian.Path The path to the note where the tag was found.
---@field line integer The line number (1-indexed) where the tag was found.
---@field text string The text (with whitespace stripped) of the line where the tag was found.
---@field tag_start integer|? The index within 'text' where the tag starts.
---@field tag_end integer|? The index within 'text' where the tag ends.

--- Find all tags starting with the given search term(s).
---
---@param term string|string[] The search term.
---@param opts { search: obsidian.SearchOpts|?, timeout: integer|? }|?
---
---@return obsidian.TagLocation[]
Client.find_tags = function(self, term, opts)
  opts = opts or {}
  return block_on(function(cb)
    return self:find_tags_async(term, cb, { search = opts.search })
  end, opts.timeout)
end

--- An async version of 'find_tags()'.
---
---@param term string|string[] The search term.
---@param callback fun(tags: obsidian.TagLocation[])
---@param opts { search: obsidian.SearchOpts }|?
Client.find_tags_async = function(self, term, callback, opts)
  opts = opts or {}

  ---@type string[]
  local terms
  if type(term) == "string" then
    terms = { term }
  else
    terms = term
  end

  for i, t in ipairs(terms) do
    if vim.startswith(t, "#") then
      terms[i] = string.sub(t, 2)
    end
  end

  terms = util.tbl_unique(terms)

  -- Maps paths to tag locations.
  ---@type table<obsidian.Path, obsidian.TagLocation[]>
  local path_to_tag_loc = {}
  -- Caches note objects.
  ---@type table<obsidian.Path, obsidian.Note>
  local path_to_note = {}
  -- Caches code block locations.
  ---@type table<obsidian.Path, { [1]: integer, [2]: integer []}>
  local path_to_code_blocks = {}
  -- Keeps track of the order of the paths.
  ---@type table<string, integer>
  local path_order = {}

  local num_paths = 0
  local err_count = 0
  local first_err = nil
  local first_err_path = nil

  local executor = AsyncExecutor.new()

  ---@param tag string
  ---@param path string|obsidian.Path
  ---@param note obsidian.Note
  ---@param lnum integer
  ---@param text string
  ---@param col_start integer|?
  ---@param col_end integer|?
  local add_match = function(tag, path, note, lnum, text, col_start, col_end)
    if vim.startswith(tag, "#") then
      tag = string.sub(tag, 2)
    end
    if not path_to_tag_loc[path] then
      path_to_tag_loc[path] = {}
    end
    path_to_tag_loc[path][#path_to_tag_loc[path] + 1] = {
      tag = tag,
      path = path,
      note = note,
      line = lnum,
      text = text,
      tag_start = col_start,
      tag_end = col_end,
    }
  end

  -- Wraps `Note.from_file_with_contents_async()` to return a table instead of a tuple and
  -- find the code blocks.
  ---@param path obsidian.Path
  ---@return { [1]: obsidian.Note, [2]: {[1]: integer, [2]: integer}[] }
  local load_note = function(path)
    local note, contents = Note.from_file_with_contents_async(path, { max_lines = Obsidian.opts.search_max_lines })
    return { note, search.find_code_blocks(contents) }
  end

  ---@param match_data MatchData
  local on_match = function(match_data)
    local path = Path.new(match_data.path.text):resolve { strict = true }

    if path_order[path] == nil then
      num_paths = num_paths + 1
      path_order[path] = num_paths
    end

    executor:submit(function()
      -- Load note.
      local note = path_to_note[path]
      local code_blocks = path_to_code_blocks[path]
      if not note or not code_blocks then
        local ok, res = pcall(load_note, path)
        if ok then
          note, code_blocks = unpack(res)
          path_to_note[path] = note
          path_to_code_blocks[path] = code_blocks
        else
          err_count = err_count + 1
          if first_err == nil then
            first_err = res
            first_err_path = path
          end
          return
        end
      end

      local line_number = match_data.line_number + 1 -- match_data.line_number is 0-indexed

      -- check if the match was inside a code block.
      for block in iter(code_blocks) do
        if block[1] <= line_number and line_number <= block[2] then
          return
        end
      end

      local line = vim.trim(match_data.lines.text)
      local n_matches = 0

      -- check for tag in the wild of the form '#{tag}'
      for match in iter(search.find_tags(line)) do
        local m_start, m_end, _ = unpack(match)
        local tag = string.sub(line, m_start + 1, m_end)
        if string.match(tag, "^" .. search.Patterns.TagCharsRequired .. "$") then
          add_match(tag, path, note, match_data.line_number, line, m_start, m_end)
        end
      end

      -- check for tags in frontmatter
      if n_matches == 0 and note.tags ~= nil and (vim.startswith(line, "tags:") or string.match(line, "%s*- ")) then
        for tag in iter(note.tags) do
          tag = tostring(tag)
          for _, t in ipairs(terms) do
            if string.len(t) == 0 or util.string_contains(tag:lower(), t:lower()) then
              add_match(tag, path, note, match_data.line_number, line)
            end
          end
        end
      end
    end)
  end

  local tx, rx = channel.oneshot()

  local search_terms = {}
  for t in iter(terms) do
    if string.len(t) > 0 then
      -- tag in the wild
      search_terms[#search_terms + 1] = "#" .. search.Patterns.TagCharsOptional .. t .. search.Patterns.TagCharsOptional
      -- frontmatter tag in multiline list
      search_terms[#search_terms + 1] = "\\s*- "
        .. search.Patterns.TagCharsOptional
        .. t
        .. search.Patterns.TagCharsOptional
        .. "$"
      -- frontmatter tag in inline list
      search_terms[#search_terms + 1] = "tags: .*"
        .. search.Patterns.TagCharsOptional
        .. t
        .. search.Patterns.TagCharsOptional
    else
      -- tag in the wild
      search_terms[#search_terms + 1] = "#" .. search.Patterns.TagCharsRequired
      -- frontmatter tag in multiline list
      search_terms[#search_terms + 1] = "\\s*- " .. search.Patterns.TagCharsRequired .. "$"
      -- frontmatter tag in inline list
      search_terms[#search_terms + 1] = "tags: .*" .. search.Patterns.TagCharsRequired
    end
  end

  search.search_async(
    Obsidian.dir,
    search_terms,
    self:_prepare_search_opts(opts.search, { ignore_case = true }),
    on_match,
    function(_)
      tx()
    end
  )

  async.run(function()
    rx()
    executor:join_async()

    ---@type obsidian.TagLocation[]
    local tags_list = {}

    -- Order by path.
    local paths = {}
    for path, idx in pairs(path_order) do
      paths[idx] = path
    end

    -- Gather results in path order.
    for _, path in ipairs(paths) do
      local tag_locs = path_to_tag_loc[path]
      if tag_locs ~= nil then
        table.sort(tag_locs, function(a, b)
          return a.line < b.line
        end)
        for _, tag_loc in ipairs(tag_locs) do
          tags_list[#tags_list + 1] = tag_loc
        end
      end
    end

    -- Log any errors.
    if first_err ~= nil and first_err_path ~= nil then
      log.err(
        "%d error(s) occurred during search. First error from note at '%s':\n%s",
        err_count,
        first_err_path,
        first_err
      )
    end

    return tags_list
  end, callback)
end

---@class obsidian.BacklinkMatches
---
---@field note obsidian.Note The note instance where the backlinks were found.
---@field path string|obsidian.Path The path to the note where the backlinks were found.
---@field matches obsidian.BacklinkMatch[] The backlinks within the note.

---@class obsidian.BacklinkMatch
---
---@field line integer The line number (1-indexed) where the backlink was found.
---@field text string The text of the line where the backlink was found.

--- Find all backlinks to a note.
---
---@param note obsidian.Note The note to find backlinks for.
---@param opts { search: obsidian.SearchOpts|?, timeout: integer|?, anchor: string|?, block: string|? }|?
---
---@return obsidian.BacklinkMatches[]
Client.find_backlinks = function(self, note, opts)
  opts = opts or {}
  return block_on(function(cb)
    return self:find_backlinks_async(note, cb, { search = opts.search, anchor = opts.anchor, block = opts.block })
  end, opts.timeout)
end

--- An async version of 'find_backlinks()'.
---
---@param note obsidian.Note The note to find backlinks for.
---@param callback fun(backlinks: obsidian.BacklinkMatches[])
---@param opts { search: obsidian.SearchOpts, anchor: string|?, block: string|? }|?
Client.find_backlinks_async = function(self, note, callback, opts)
  opts = opts or {}

  ---@type string|?
  local block = opts.block and util.standardize_block(opts.block) or nil
  local anchor = opts.anchor and util.standardize_anchor(opts.anchor) or nil
  ---@type obsidian.note.HeaderAnchor|?
  local anchor_obj
  if anchor then
    anchor_obj = note:resolve_anchor_link(anchor)
  end

  -- Maps paths (string) to note object and a list of matches.
  ---@type table<string, obsidian.BacklinkMatch[]>
  local backlink_matches = {}
  ---@type table<string, obsidian.Note>
  local path_to_note = {}
  -- Keeps track of the order of the paths.
  ---@type table<string, integer>
  local path_order = {}
  local num_paths = 0
  local err_count = 0
  local first_err = nil
  local first_err_path = nil

  local executor = AsyncExecutor.new()

  -- Prepare search terms.
  local search_terms = {}
  local note_path = Path.new(note.path)
  for raw_ref in iter { tostring(note.id), note_path.name, note_path.stem, note.path:vault_relative_path() } do
    for ref in
      iter(util.tbl_unique {
        raw_ref,
        util.urlencode(tostring(raw_ref)),
        util.urlencode(tostring(raw_ref), { keep_path_sep = true }),
      })
    do
      if ref ~= nil then
        if anchor == nil and block == nil then
          -- Wiki links without anchor/block.
          search_terms[#search_terms + 1] = string.format("[[%s]]", ref)
          search_terms[#search_terms + 1] = string.format("[[%s|", ref)
          -- Markdown link without anchor/block.
          search_terms[#search_terms + 1] = string.format("(%s)", ref)
          -- Markdown link without anchor/block and is relative to root.
          search_terms[#search_terms + 1] = string.format("(/%s)", ref)
          -- Wiki links with anchor/block.
          search_terms[#search_terms + 1] = string.format("[[%s#", ref)
          -- Markdown link with anchor/block.
          search_terms[#search_terms + 1] = string.format("(%s#", ref)
          -- Markdown link with anchor/block and is relative to root.
          search_terms[#search_terms + 1] = string.format("(/%s#", ref)
        elseif anchor then
          -- Note: Obsidian allow a lot of different forms of anchor links, so we can't assume
          -- it's the standardized form here.
          -- Wiki links with anchor.
          search_terms[#search_terms + 1] = string.format("[[%s#", ref)
          -- Markdown link with anchor.
          search_terms[#search_terms + 1] = string.format("(%s#", ref)
          -- Markdown link with anchor and is relative to root.
          search_terms[#search_terms + 1] = string.format("(/%s#", ref)
        elseif block then
          -- Wiki links with block.
          search_terms[#search_terms + 1] = string.format("[[%s#%s", ref, block)
          -- Markdown link with block.
          search_terms[#search_terms + 1] = string.format("(%s#%s", ref, block)
          -- Markdown link with block and is relative to root.
          search_terms[#search_terms + 1] = string.format("(/%s#%s", ref, block)
        end
      end
    end
  end
  for alias in iter(note.aliases) do
    if anchor == nil and block == nil then
      -- Wiki link without anchor/block.
      search_terms[#search_terms + 1] = string.format("[[%s]]", alias)
      -- Wiki link with anchor/block.
      search_terms[#search_terms + 1] = string.format("[[%s#", alias)
    elseif anchor then
      -- Wiki link with anchor.
      search_terms[#search_terms + 1] = string.format("[[%s#", alias)
    elseif block then
      -- Wiki link with block.
      search_terms[#search_terms + 1] = string.format("[[%s#%s", alias, block)
    end
  end

  ---@type obsidian.note.LoadOpts
  local load_opts = {
    collect_anchor_links = opts.anchor ~= nil,
    collect_blocks = opts.block ~= nil,
    max_lines = Obsidian.opts.search_max_lines,
  }

  ---@param match MatchData
  local function on_match(match)
    local path = Path.new(match.path.text):resolve { strict = true }

    if path_order[path] == nil then
      num_paths = num_paths + 1
      path_order[path] = num_paths
    end

    executor:submit(function()
      -- Load note.
      local n = path_to_note[path]
      if not n then
        local ok, res = pcall(Note.from_file_async, path, load_opts)
        if ok then
          n = res
          path_to_note[path] = n
        else
          err_count = err_count + 1
          if first_err == nil then
            first_err = res
            first_err_path = path
          end
          return
        end
      end

      if anchor then
        -- Check for a match with the anchor.
        -- NOTE: no need to do this with blocks, since blocks are standardized.
        local match_text = string.sub(match.lines.text, match.submatches[1].start)
        local link_location = util.parse_link(match_text)
        if not link_location then
          log.error("Failed to parse reference from '%s' ('%s')", match_text, match)
          return
        end

        local anchor_link = select(2, util.strip_anchor_links(link_location))
        if not anchor_link then
          return
        end

        if anchor_link ~= anchor and anchor_obj ~= nil then
          local resolved_anchor = note:resolve_anchor_link(anchor_link)
          if resolved_anchor == nil or resolved_anchor.header ~= anchor_obj.header then
            return
          end
        end
      end

      ---@type obsidian.BacklinkMatch[]
      local line_matches = backlink_matches[path]
      if line_matches == nil then
        line_matches = {}
        backlink_matches[path] = line_matches
      end

      line_matches[#line_matches + 1] = {
        line = match.line_number,
        text = util.rstrip_whitespace(match.lines.text),
      }
    end)
  end

  local tx, rx = channel.oneshot()

  -- Execute search.
  search.search_async(
    Obsidian.dir,
    util.tbl_unique(search_terms),
    self:_prepare_search_opts(opts.search, { fixed_strings = true, ignore_case = true }),
    on_match,
    function()
      tx()
    end
  )

  async.run(function()
    rx()
    executor:join_async()

    ---@type obsidian.BacklinkMatches[]
    local results = {}

    -- Order by path.
    local paths = {}
    for path, idx in pairs(path_order) do
      paths[idx] = path
    end

    -- Gather results.
    for i, path in ipairs(paths) do
      results[i] = { note = path_to_note[path], path = path, matches = backlink_matches[path] }
    end

    -- Log any errors.
    if first_err ~= nil and first_err_path ~= nil then
      log.err(
        "%d error(s) occurred during search. First error from note at '%s':\n%s",
        err_count,
        first_err_path,
        first_err
      )
    end

    return vim.tbl_filter(function(bl)
      return bl.matches ~= nil
    end, results)
  end, callback)
end

--- Gather a list of all tags in the vault. If 'term' is provided, only tags that partially match the search
--- term will be included.
---
---@param term string|? An optional search term to match tags
---@param timeout integer|? Timeout in milliseconds
---
---@return string[]
Client.list_tags = function(self, term, timeout)
  local tags = {}
  for _, tag_loc in ipairs(self:find_tags(term and term or "", { timeout = timeout })) do
    tags[tag_loc.tag] = true
  end
  return vim.tbl_keys(tags)
end

--- An async version of 'list_tags()'.
---
---@param term string|?
---@param callback fun(tags: string[])
Client.list_tags_async = function(self, term, callback)
  self:find_tags_async(term and term or "", function(tag_locations)
    local tags = {}
    for _, tag_loc in ipairs(tag_locations) do
      local tag = tag_loc.tag:lower()
      if not tags[tag] then
        tags[tag] = true
      end
    end
    callback(vim.tbl_keys(tags))
  end)
end

return Client

local abc = require "obsidian.abc"
local util = require "obsidian.util"
local log = require "obsidian.log"
local search = require "obsidian.search"
local iter = vim.iter

local M = {}

local NAMESPACE = "ObsidianUI"

---@param ui_opts obsidian.config.UIOpts
local function install_hl_groups(ui_opts)
  for group_name, opts in pairs(ui_opts.hl_groups) do
    vim.api.nvim_set_hl(0, group_name, opts)
  end
end

-- We cache marks locally to help avoid redrawing marks when its not necessary. The main reason
-- we need to do this is because the conceal char we get back from `nvim_buf_get_extmarks()` gets mangled.
-- For example, "󰄱" is turned into "1\1\15".
-- TODO: if we knew how to un-mangle the conceal char we wouldn't need the cache.

M._buf_mark_cache = vim.defaulttable()

---@param bufnr integer
---@param ns_id integer
---@param mark_id integer
---@return ExtMark|?
local function cache_get(bufnr, ns_id, mark_id)
  local buf_ns_cache = M._buf_mark_cache[bufnr][ns_id]
  return buf_ns_cache[mark_id]
end

---@param bufnr integer
---@param ns_id integer
---@param mark ExtMark
---@return ExtMark|?
local function cache_set(bufnr, ns_id, mark)
  assert(mark.id ~= nil)
  M._buf_mark_cache[bufnr][ns_id][mark.id] = mark
end

---@param bufnr integer
---@param ns_id integer
---@param mark_id integer
local function cache_evict(bufnr, ns_id, mark_id)
  M._buf_mark_cache[bufnr][ns_id][mark_id] = nil
end

---@param bufnr integer
---@param ns_id integer
local function cache_clear(bufnr, ns_id)
  M._buf_mark_cache[bufnr][ns_id] = {}
end

---@class ExtMark : obsidian.ABC
---@field id integer|? ID of the mark, only set for marks that are actually materialized in the buffer.
---@field row integer 0-based row index to place the mark.
---@field col integer 0-based col index to place the mark.
---@field opts ExtMarkOpts Optional parameters passed directly to `nvim_buf_set_extmark()`.
local ExtMark = abc.new_class {
  __eq = function(a, b)
    return a.row == b.row and a.col == b.col and a.opts == b.opts
  end,
}

M.ExtMark = ExtMark

---@class ExtMarkOpts : obsidian.ABC
---@field end_row integer
---@field end_col integer
---@field conceal string|?
---@field hl_group string|?
---@field spell boolean|?
local ExtMarkOpts = abc.new_class()

M.ExtMarkOpts = ExtMarkOpts

---@param data table
---@return ExtMarkOpts
ExtMarkOpts.from_tbl = function(data)
  local self = ExtMarkOpts.init()
  self.end_row = data.end_row
  self.end_col = data.end_col
  self.conceal = data.conceal
  self.hl_group = data.hl_group
  self.spell = data.spell
  return self
end

---@param self ExtMarkOpts
---@return table
ExtMarkOpts.to_tbl = function(self)
  return {
    end_row = self.end_row,
    end_col = self.end_col,
    conceal = self.conceal,
    hl_group = self.hl_group,
    spell = self.spell,
  }
end

---@param id integer|?
---@param row integer
---@param col integer
---@param opts ExtMarkOpts
---@return ExtMark
ExtMark.new = function(id, row, col, opts)
  local self = ExtMark.init()
  self.id = id
  self.row = row
  self.col = col
  self.opts = opts
  return self
end

---Materialize the ExtMark if needed. After calling this the 'id' will be set if it wasn't already.
---@param self ExtMark
---@param bufnr integer
---@param ns_id integer
---@return ExtMark
ExtMark.materialize = function(self, bufnr, ns_id)
  if self.id == nil then
    self.id = vim.api.nvim_buf_set_extmark(bufnr, ns_id, self.row, self.col, self.opts:to_tbl())
  end
  cache_set(bufnr, ns_id, self)
  return self
end

---@param self ExtMark
---@param bufnr integer
---@param ns_id integer
---@return boolean
ExtMark.clear = function(self, bufnr, ns_id)
  if self.id ~= nil then
    cache_evict(bufnr, ns_id, self.id)
    return vim.api.nvim_buf_del_extmark(bufnr, ns_id, self.id)
  else
    return false
  end
end

---Collect all existing (materialized) marks within a region.
---@param bufnr integer
---@param ns_id integer
---@param region_start integer|integer[]|?
---@param region_end integer|integer[]|?
---@return ExtMark[]
ExtMark.collect = function(bufnr, ns_id, region_start, region_end)
  region_start = region_start and region_start or 0
  region_end = region_end and region_end or -1
  local marks = {}
  for data in iter(vim.api.nvim_buf_get_extmarks(bufnr, ns_id, region_start, region_end, { details = true })) do
    local mark = ExtMark.new(data[1], data[2], data[3], ExtMarkOpts.from_tbl(data[4]))
    -- NOTE: since the conceal char we get back from `nvim_buf_get_extmarks()` is mangled, e.g.
    -- "󰄱" is turned into "1\1\15", we used the cached version.
    local cached_mark = cache_get(bufnr, ns_id, mark.id)
    if cached_mark ~= nil then
      mark.opts.conceal = cached_mark.opts.conceal
    end
    cache_set(bufnr, ns_id, mark)
    marks[#marks + 1] = mark
  end
  return marks
end

---Clear all existing (materialized) marks with a line region.
---@param bufnr integer
---@param ns_id integer
---@param line_start integer
---@param line_end integer
ExtMark.clear_range = function(bufnr, ns_id, line_start, line_end)
  return vim.api.nvim_buf_clear_namespace(bufnr, ns_id, line_start, line_end)
end

---Clear all existing (materialized) marks on a line.
---@param bufnr integer
---@param ns_id integer
---@param line integer
ExtMark.clear_line = function(bufnr, ns_id, line)
  return ExtMark.clear_range(bufnr, ns_id, line, line + 1)
end

---@param marks ExtMark[]
---@param lnum integer
---@param ui_opts obsidian.config.UIOpts
---@return ExtMark[]
local function get_line_check_extmarks(marks, line, lnum, ui_opts)
  for char, opts in pairs(ui_opts.checkboxes) do
    if string.match(line, "^%s*- %[" .. vim.pesc(char) .. "%]") then
      local indent = util.count_indent(line)
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        indent,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = indent + 5,
          conceal = opts.char,
          hl_group = opts.hl_group,
        }
      )
      return marks
    end
  end

  if ui_opts.bullets ~= nil and string.match(line, "^%s*[-%*%+] ") then
    local indent = util.count_indent(line)
    marks[#marks + 1] = ExtMark.new(
      nil,
      lnum,
      indent,
      ExtMarkOpts.from_tbl {
        end_row = lnum,
        end_col = indent + 1,
        conceal = ui_opts.bullets.char,
        hl_group = ui_opts.bullets.hl_group,
      }
    )
  end

  return marks
end

---@param marks ExtMark[]
---@param lnum integer
---@param ui_opts obsidian.config.UIOpts
---@return ExtMark[]
local function get_line_ref_extmarks(marks, line, lnum, ui_opts)
  local matches = search.find_refs(line, { include_naked_urls = true, include_tags = true, include_block_ids = true })
  for match in iter(matches) do
    local m_start, m_end, m_type = unpack(match)
    if m_type == search.RefTypes.WikiWithAlias then
      -- Reference of the form [[xxx|yyy]]
      local pipe_loc = string.find(line, "|", m_start, true)
      assert(pipe_loc)
      -- Conceal everything from '[[' up to '|'
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_start - 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = pipe_loc,
          conceal = "",
        }
      )
      -- Highlight the alias 'yyy'
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        pipe_loc,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_end - 2,
          hl_group = ui_opts.reference_text.hl_group,
          spell = false,
        }
      )
      -- Conceal the closing ']]'
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_end - 2,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_end,
          conceal = "",
        }
      )
    elseif m_type == search.RefTypes.Wiki then
      -- Reference of the form [[xxx]]
      -- Conceal the opening '[['
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_start - 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_start + 1,
          conceal = "",
        }
      )
      -- Highlight the ref 'xxx'
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_start + 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_end - 2,
          hl_group = ui_opts.reference_text.hl_group,
          spell = false,
        }
      )
      -- Conceal the closing ']]'
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_end - 2,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_end,
          conceal = "",
        }
      )
    elseif m_type == search.RefTypes.Markdown then
      -- Reference of the form [yyy](xxx)
      local closing_bracket_loc = string.find(line, "]", m_start, true)
      assert(closing_bracket_loc)
      local is_url = util.is_url(string.sub(line, closing_bracket_loc + 2, m_end - 1))
      -- Conceal the opening '['
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_start - 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_start,
          conceal = "",
        }
      )
      -- Highlight the ref 'yyy'
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_start,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = closing_bracket_loc - 1,
          hl_group = ui_opts.reference_text.hl_group,
          spell = false,
        }
      )
      -- Conceal the ']('
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        closing_bracket_loc - 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = closing_bracket_loc + 1,
          conceal = is_url and " " or "",
        }
      )
      -- Conceal the URL part 'xxx' with the external URL character
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        closing_bracket_loc + 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_end - 1,
          conceal = is_url and ui_opts.external_link_icon.char or "",
          hl_group = ui_opts.external_link_icon.hl_group,
        }
      )
      -- Conceal the closing ')'
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_end - 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_end,
          conceal = is_url and " " or "",
        }
      )
    elseif m_type == search.RefTypes.NakedUrl then
      -- A "naked" URL is just a URL by itself, like 'https://github.com/'
      local domain_start_loc = string.find(line, "://", m_start, true)
      assert(domain_start_loc)
      domain_start_loc = domain_start_loc + 3
      -- Conceal the "https?://" part
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_start - 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = domain_start_loc - 1,
          conceal = "",
        }
      )
      -- Highlight the whole thing.
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_start - 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_end,
          hl_group = ui_opts.reference_text.hl_group,
          spell = false,
        }
      )
    elseif m_type == search.RefTypes.Tag then
      -- A tag is like '#tag'
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_start - 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_end,
          hl_group = ui_opts.tags.hl_group,
          spell = false,
        }
      )
    elseif m_type == search.RefTypes.BlockID then
      -- A block ID, like '^hello-world'
      marks[#marks + 1] = ExtMark.new(
        nil,
        lnum,
        m_start - 1,
        ExtMarkOpts.from_tbl {
          end_row = lnum,
          end_col = m_end,
          hl_group = ui_opts.block_ids.hl_group,
          spell = false,
        }
      )
    end
  end
  return marks
end

---@param marks ExtMark[]
---@param lnum integer
---@param ui_opts obsidian.config.UIOpts
---@return ExtMark[]
local function get_line_highlight_extmarks(marks, line, lnum, ui_opts)
  local matches = search.find_highlight(line)
  for match in iter(matches) do
    local m_start, m_end, _ = unpack(match)
    -- Conceal opening '=='
    marks[#marks + 1] = ExtMark.new(
      nil,
      lnum,
      m_start - 1,
      ExtMarkOpts.from_tbl {
        end_row = lnum,
        end_col = m_start + 1,
        conceal = "",
      }
    )
    -- Highlight text in the middle
    marks[#marks + 1] = ExtMark.new(
      nil,
      lnum,
      m_start + 1,
      ExtMarkOpts.from_tbl {
        end_row = lnum,
        end_col = m_end - 2,
        hl_group = ui_opts.highlight_text.hl_group,
        spell = false,
      }
    )
    -- Conceal closing '=='
    marks[#marks + 1] = ExtMark.new(
      nil,
      lnum,
      m_end - 2,
      ExtMarkOpts.from_tbl {
        end_row = lnum,
        end_col = m_end,
        conceal = "",
      }
    )
  end
  return marks
end

---@param lnum integer
---@param ui_opts obsidian.config.UIOpts
---@return ExtMark[]
local get_line_marks = function(line, lnum, ui_opts)
  local marks = {}
  get_line_check_extmarks(marks, line, lnum, ui_opts)
  get_line_ref_extmarks(marks, line, lnum, ui_opts)
  get_line_highlight_extmarks(marks, line, lnum, ui_opts)
  return marks
end

---@param bufnr integer
---@param ui_opts obsidian.config.UIOpts
local function update_extmarks(bufnr, ns_id, ui_opts)
  local start_time = vim.uv.hrtime()
  local n_marks_added = 0
  local n_marks_cleared = 0

  -- Collect all current marks, grouped by line.
  local cur_marks_by_line = vim.defaulttable()
  for mark in iter(ExtMark.collect(bufnr, ns_id)) do
    local cur_line_marks = cur_marks_by_line[mark.row]
    cur_line_marks[#cur_line_marks + 1] = mark
  end

  -- Iterate over lines (skipping code blocks) and update marks.
  local inside_code_block = false
  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)
  for i, line in ipairs(lines) do
    local lnum = i - 1
    local cur_line_marks = cur_marks_by_line[lnum]

    local function clear_line()
      ExtMark.clear_line(bufnr, ns_id, lnum)
      n_marks_cleared = n_marks_cleared + #cur_line_marks
      for mark in iter(cur_line_marks) do
        cache_evict(bufnr, ns_id, mark.id)
      end
    end

    -- Check if inside a code block or at code block boundary. If not, update marks.
    if string.match(line, "^%s*```[^`]*$") then
      inside_code_block = not inside_code_block
      -- Remove any existing marks here on the boundary of a code block.
      clear_line()
    elseif not inside_code_block then
      -- Get all marks that should be materialized.
      -- Some of these might already be materialized, which we'll check below and avoid re-drawing
      -- if that's the case.
      local new_line_marks = get_line_marks(line, lnum, ui_opts)
      if #new_line_marks > 0 then
        -- Materialize new marks.
        for mark in iter(new_line_marks) do
          if not vim.list_contains(cur_line_marks, mark) then
            mark:materialize(bufnr, ns_id)
            n_marks_added = n_marks_added + 1
          end
        end

        -- Clear old marks.
        for mark in iter(cur_line_marks) do
          if not vim.list_contains(new_line_marks, mark) then
            mark:clear(bufnr, ns_id)
            n_marks_cleared = n_marks_cleared + 1
          end
        end
      else
        -- Remove any existing marks here since there are no new marks.
        clear_line()
      end
    else
      -- Remove any existing marks here since we're inside a code block.
      clear_line()
    end
  end

  local runtime = math.floor((vim.uv.hrtime() - start_time) / 1000000)
  log.debug("Added %d new marks, cleared %d old marks in %dms", n_marks_added, n_marks_cleared, runtime)
end

---@param ui_opts obsidian.config.UIOpts
---@param bufnr integer|?
---@return boolean
local function should_update(ui_opts, bufnr)
  if ui_opts.enable == false then
    return false
  end

  bufnr = bufnr or 0

  if not vim.endswith(vim.api.nvim_buf_get_name(bufnr), ".md") then
    return false
  end

  if ui_opts.max_file_length ~= nil and vim.fn.line "$" > ui_opts.max_file_length then
    return false
  end

  return true
end

---@param ui_opts obsidian.config.UIOpts
---@param throttle boolean
---@return function
local function get_extmarks_autocmd_callback(ui_opts, throttle)
  local ns_id = vim.api.nvim_create_namespace(NAMESPACE)

  local callback = function(ev)
    if not should_update(ui_opts, ev.bufnr) then
      return
    end
    update_extmarks(ev.buf, ns_id, ui_opts)
  end

  if throttle then
    return require("obsidian.async").throttle(callback, ui_opts.update_debounce)
  else
    return callback
  end
end

---Manually update extmarks.
---
---@param bufnr integer|?
M.update = function(bufnr)
  bufnr = bufnr or 0
  local ui_opts = Obsidian.opts.ui
  if not should_update(ui_opts, bufnr) then
    return
  end
  local ns_id = vim.api.nvim_create_namespace(NAMESPACE)
  update_extmarks(bufnr, ns_id, ui_opts)
end

---@param workspace obsidian.Workspace
---@param ui_opts obsidian.config.UIOpts
M.setup = function(workspace, ui_opts)
  if ui_opts.enable == false then
    return
  end

  local group = vim.api.nvim_create_augroup("ObsidianUI" .. workspace.name, { clear = true })

  install_hl_groups(ui_opts)

  local pattern = tostring(workspace.root) .. "/**.md"

  vim.api.nvim_create_autocmd({ "BufEnter" }, {
    group = group,
    pattern = pattern,
    callback = function()
      local conceallevel = vim.opt_local.conceallevel:get()

      if (conceallevel < 1 or conceallevel > 2) and not ui_opts.ignore_conceal_warn then
        log.warn_once(
          "Obsidian additional syntax features require 'conceallevel' to be set to 1 or 2, "
            .. "but you have 'conceallevel' set to '%s'.\n"
            .. "See https://github.com/epwalsh/obsidian.nvim/issues/286 for more details.\n"
            .. "If you don't want Obsidian's additional UI features, you can disable them and suppress "
            .. "this warning by setting 'ui.enable = false' in your Obsidian nvim config.",
          conceallevel
        )
      end

      -- delete the autocommand
      return true
    end,
  })

  vim.api.nvim_create_autocmd({ "BufEnter" }, {
    group = group,
    pattern = pattern,
    callback = get_extmarks_autocmd_callback(ui_opts, false),
  })

  vim.api.nvim_create_autocmd({ "BufEnter", "TextChanged", "TextChangedI", "TextChangedP" }, {
    group = group,
    pattern = pattern,
    callback = get_extmarks_autocmd_callback(ui_opts, true),
  })

  vim.api.nvim_create_autocmd({ "BufUnload" }, {
    group = group,
    pattern = pattern,
    callback = function(ev)
      local ns_id = vim.api.nvim_create_namespace(NAMESPACE)
      cache_clear(ev.buf, ns_id)
    end,
  })
end

return M

local api = require "obsidian.api"
local log = require "obsidian.log"
local img = require "obsidian.img_paste"

---@param data CommandArgs
return function(_, data)
  if not img.clipboard_is_img() then
    return log.err "There is no image data in the clipboard"
  end

  ---@type string|?
  local default_name = Obsidian.opts.attachments.img_name_func()

  local should_confirm = Obsidian.opts.attachments.confirm_img_paste

  ---@type string
  local fname = vim.trim(data.args)

  -- Get filename to save to.
  if fname == nil or fname == "" then
    if default_name and not should_confirm then
      fname = default_name
    else
      local input = api.input("Enter file name: ", { default = default_name, completion = "file" })
      if not input then
        return log.warn "Paste aborted"
      end
      fname = input
    end
  end

  local path = api.resolve_image_path(fname)

  img.paste(path)
end

local abc = require "obsidian.abc"
local completion = require "obsidian.completion.refs"
local obsidian = require "obsidian"
local util = require "obsidian.util"
local api = require "obsidian.api"
local LinkStyle = require("obsidian.config").LinkStyle
local Note = require "obsidian.note"
local Path = require "obsidian.path"

---Used to track variables that are used between reusable method calls. This is required, because each
---call to the sources's completion hook won't create a new source object, but will reuse the same one.
---@class obsidian.completion.sources.base.NewNoteSourceCompletionContext : obsidian.ABC
---@field client obsidian.Client
---@field completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback
---@field request obsidian.completion.sources.base.Request
---@field search string|?
---@field insert_start integer|?
---@field insert_end integer|?
---@field ref_type obsidian.completion.RefType|?
local NewNoteSourceCompletionContext = abc.new_class()

NewNoteSourceCompletionContext.new = function()
  return NewNoteSourceCompletionContext.init()
end

---@class obsidian.completion.sources.base.NewNoteSourceBase : obsidian.ABC
---@field incomplete_response table
---@field complete_response table
local NewNoteSourceBase = abc.new_class()

---@return obsidian.completion.sources.base.NewNoteSourceBase
NewNoteSourceBase.new = function()
  return NewNoteSourceBase.init()
end

NewNoteSourceBase.get_trigger_characters = completion.get_trigger_characters

---Sets up a new completion context that is used to pass around variables between completion source methods
---@param completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback
---@param request obsidian.completion.sources.base.Request
---@return obsidian.completion.sources.base.NewNoteSourceCompletionContext
function NewNoteSourceBase:new_completion_context(completion_resolve_callback, request)
  local completion_context = NewNoteSourceCompletionContext.new()

  -- Sets up the completion callback, which will be called when the (possibly incomplete) completion items are ready
  completion_context.completion_resolve_callback = completion_resolve_callback

  -- This request object will be used to determine the current cursor location and the text around it
  completion_context.request = request

  completion_context.client = assert(obsidian.get_client())

  return completion_context
end

--- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods
---@param cc obsidian.completion.sources.base.NewNoteSourceCompletionContext
function NewNoteSourceBase:process_completion(cc)
  if not self:can_complete_request(cc) then
    return
  end

  ---@type string|?
  local block_link
  cc.search, block_link = util.strip_block_links(cc.search)

  ---@type string|?
  local anchor_link
  cc.search, anchor_link = util.strip_anchor_links(cc.search)

  -- If block link is incomplete, do nothing.
  if not block_link and vim.endswith(cc.search, "#^") then
    cc.completion_resolve_callback(self.incomplete_response)
    return
  end

  -- If anchor link is incomplete, do nothing.
  if not anchor_link and vim.endswith(cc.search, "#") then
    cc.completion_resolve_callback(self.incomplete_response)
    return
  end

  -- Probably just a block/anchor link within current note.
  if string.len(cc.search) == 0 then
    cc.completion_resolve_callback(self.incomplete_response)
    return
  end

  -- Create a mock block.
  ---@type obsidian.note.Block|?
  local block
  if block_link then
    block = { block = "", id = util.standardize_block(block_link), line = 1 }
  end

  -- Create a mock anchor.
  ---@type obsidian.note.HeaderAnchor|?
  local anchor
  if anchor_link then
    anchor = { anchor = anchor_link, header = string.sub(anchor_link, 2), level = 1, line = 1 }
  end

  ---@type { label: string, note: obsidian.Note, template: string|? }[]
  local new_notes_opts = {}

  local note = Note.create { title = cc.search }
  if note.title and string.len(note.title) > 0 then
    new_notes_opts[#new_notes_opts + 1] = { label = cc.search, note = note }
  end

  -- Check for datetime macros.
  for _, dt_offset in ipairs(util.resolve_date_macro(cc.search)) do
    if dt_offset.cadence == "daily" then
      note = require("obsidian.daily").daily(dt_offset.offset, { no_write = true })
      if not note:exists() then
        new_notes_opts[#new_notes_opts + 1] =
          { label = dt_offset.macro, note = note, template = Obsidian.opts.daily_notes.template }
      end
    end
  end

  -- Completion items.
  local items = {}

  for _, new_note_opts in ipairs(new_notes_opts) do
    local new_note = new_note_opts.note

    assert(new_note.path)

    ---@type obsidian.config.LinkStyle, string
    local link_style, label
    if cc.ref_type == completion.RefType.Wiki then
      link_style = LinkStyle.wiki
      label = string.format("[[%s]] (create)", new_note_opts.label)
    elseif cc.ref_type == completion.RefType.Markdown then
      link_style = LinkStyle.markdown
      label = string.format("[%s](…) (create)", new_note_opts.label)
    else
      error "not implemented"
    end

    local new_text = api.format_link(new_note, { link_style = link_style, anchor = anchor, block = block })
    local documentation = {
      kind = "markdown",
      value = new_note:display_info {
        label = "Create: " .. new_text,
      },
    }

    items[#items + 1] = {
      documentation = documentation,
      sortText = new_note_opts.label,
      label = label,
      kind = vim.lsp.protocol.CompletionItemKind.Reference,
      textEdit = {
        newText = new_text,
        range = {
          start = {
            line = cc.request.context.cursor.row - 1,
            character = cc.insert_start,
          },
          ["end"] = {
            line = cc.request.context.cursor.row - 1,
            character = cc.insert_end + 1,
          },
        },
      },
      data = {
        note = new_note,
        template = new_note_opts.template,
      },
    }
  end

  cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = items }))
end

--- Returns whatever it's possible to complete the search and sets up the search related variables in cc
---@param cc obsidian.completion.sources.base.NewNoteSourceCompletionContext
---@return boolean success provides a chance to return early if the request didn't meet the requirements
function NewNoteSourceBase:can_complete_request(cc)
  local can_complete
  can_complete, cc.search, cc.insert_start, cc.insert_end, cc.ref_type = completion.can_complete(cc.request)

  if cc.search ~= nil then
    cc.search = util.lstrip_whitespace(cc.search)
  end

  if not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then
    cc.completion_resolve_callback(self.incomplete_response)
    return false
  end
  return true
end

--- Runs a generalized version of the execute method
---@param item any
---@return table|? callback_return_value
function NewNoteSourceBase:process_execute(item)
  local data = item.data

  if data == nil then
    return nil
  end

  -- Make sure `data.note` is actually an `obsidian.Note` object. If it gets serialized at some
  -- point (seems to happen on Linux), it will lose its metatable.
  if not Note.is_note_obj(data.note) then
    data.note = setmetatable(data.note, Note.mt)
    data.note.path = setmetatable(data.note.path, Path.mt)
  end

  data.note:write { template = data.template }
  return {}
end

return NewNoteSourceBase

vim.env.LAZY_STDPATH = ".repro"
load(vim.fn.system "curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua")()

vim.fn.mkdir(".repro/vault", "p")

vim.o.conceallevel = 2

local plugins = {
  {
    "obsidian-nvim/obsidian.nvim",
    dependencies = { "nvim-lua/plenary.nvim" },
    opts = {
      completion = {
        blink = true,
        nvim_cmp = false,
      },
      workspaces = {
        {
          name = "test",
          path = vim.fs.joinpath(vim.uv.cwd(), ".repro", "vault"),
        },
      },
    },
  },

  -- **Choose your renderer**
  -- { "MeanderingProgrammer/render-markdown.nvim", dependencies = { "echasnovski/mini.icons" }, opts = {} },
  -- { "OXY2DEV/markview.nvim", lazy = false },

  -- **Choose your picker**
  -- "nvim-telescope/telescope.nvim",
  -- "folke/snacks.nvim",
  -- "ibhagwan/fzf-lua",
  -- "echasnovski/mini.pick",

  -- **Choose your completion engine**
  -- {
  --   "hrsh7th/nvim-cmp",
  --   config = function()
  --     local cmp = require "cmp"
  --     cmp.setup {
  --       mapping = cmp.mapping.preset.insert {
  --         ["<C-e>"] = cmp.mapping.abort(),
  --         ["<C-y>"] = cmp.mapping.confirm { select = true },
  --       },
  --     }
  --   end,
  -- },
  -- {
  --   "saghen/blink.cmp",
  --   opts = {
  --     fuzzy = { implementation = "lua" }, -- no need to build binary
  --     keymap = {
  --       preset = "default",
  --     },
  --   },
  -- },
}

require("lazy.minit").repro { spec = plugins }

vim.cmd "checkhealth obsidian"

local Path = require "obsidian.path"
local log = require "obsidian.log"
local run_job = require("obsidian.async").run_job
local api = require "obsidian.api"
local util = require "obsidian.util"

local M = {}

-- Image pasting adapted from https://github.com/ekickx/clipboard-image.nvim

---@return string
local function get_clip_check_command()
  local check_cmd
  local this_os = api.get_os()
  if this_os == api.OSType.Linux or this_os == api.OSType.FreeBSD then
    local display_server = os.getenv "XDG_SESSION_TYPE"
    if display_server == "x11" or display_server == "tty" then
      check_cmd = "xclip -selection clipboard -o -t TARGETS"
    elseif display_server == "wayland" then
      check_cmd = "wl-paste --list-types"
    end
  elseif this_os == api.OSType.Darwin then
    check_cmd = "pngpaste -b 2>&1"
  elseif this_os == api.OSType.Windows or this_os == api.OSType.Wsl then
    check_cmd = 'powershell.exe "Get-Clipboard -Format Image"'
  else
    error("image saving not implemented for OS '" .. this_os .. "'")
  end
  return check_cmd
end

--- Check if clipboard contains image data.
---
---@return boolean
function M.clipboard_is_img()
  local check_cmd = get_clip_check_command()
  local result_string = vim.fn.system(check_cmd)
  local content = vim.split(result_string, "\n")

  local is_img = false
  -- See: [Data URI scheme](https://en.wikipedia.org/wiki/Data_URI_scheme)
  local this_os = api.get_os()
  if this_os == api.OSType.Linux or this_os == api.OSType.FreeBSD then
    if vim.tbl_contains(content, "image/png") then
      is_img = true
    elseif vim.tbl_contains(content, "text/uri-list") then
      local success =
        os.execute "wl-paste --type text/uri-list | sed 's|file://||' | head -n1 | tr -d '[:space:]' | xargs -I{} sh -c 'wl-copy < \"$1\"' _ {}"
      is_img = success == 0
    end
  elseif this_os == api.OSType.Darwin then
    is_img = string.sub(content[1], 1, 9) == "iVBORw0KG" -- Magic png number in base64
  elseif this_os == api.OSType.Windows or this_os == api.OSType.Wsl then
    is_img = content ~= nil
  else
    error("image saving not implemented for OS '" .. this_os .. "'")
  end
  return is_img
end

--- TODO: refactor with run_job?

--- Save image from clipboard to `path`.
---@param path string
---
---@return boolean|integer|? result
local function save_clipboard_image(path)
  local this_os = api.get_os()

  if this_os == api.OSType.Linux or this_os == api.OSType.FreeBSD then
    local cmd
    local display_server = os.getenv "XDG_SESSION_TYPE"
    if display_server == "x11" or display_server == "tty" then
      cmd = string.format("xclip -selection clipboard -t image/png -o > '%s'", path)
    elseif display_server == "wayland" then
      cmd = string.format("wl-paste --no-newline --type image/png > %s", vim.fn.shellescape(path))
      return run_job { "bash", "-c", cmd }
    end

    local result = os.execute(cmd)
    if type(result) == "number" and result > 0 then
      return false
    else
      return result
    end
  elseif this_os == api.OSType.Windows or this_os == api.OSType.Wsl then
    local cmd = 'powershell.exe -c "'
      .. string.format("(get-clipboard -format image).save('%s', 'png')", string.gsub(path, "/", "\\"))
      .. '"'
    return os.execute(cmd)
  elseif this_os == api.OSType.Darwin then
    return run_job { "pngpaste", path }
  else
    error("image saving not implemented for OS '" .. this_os .. "'")
  end
end

--- @param path string image_path The absolute path to the image file.
M.paste = function(path)
  if util.contains_invalid_characters(path) then
    log.warn "Links will not work with file names containing any of these characters in Obsidian: # ^ [ ] |"
  end

  ---@diagnostic disable-next-line: cast-local-type
  path = Path.new(path)

  -- Make sure fname ends with ".png"
  if not path.suffix then
    ---@diagnostic disable-next-line: cast-local-type
    path = path:with_suffix ".png"
  elseif path.suffix ~= ".png" then
    return log.err("invalid suffix for image name '%s', must be '.png'", path.suffix)
  end

  if Obsidian.opts.attachments.confirm_img_paste then
    -- Get confirmation from user.
    if not api.confirm("Saving image to '" .. tostring(path) .. "'. Do you want to continue?") then
      return log.warn "Paste aborted"
    end
  end

  -- Ensure parent directory exists.
  assert(path:parent()):mkdir { exist_ok = true, parents = true }

  -- Paste image.
  local result = save_clipboard_image(tostring(path))
  if result == false then
    log.err "Failed to save image"
    return
  end

  local img_text = Obsidian.opts.attachments.img_text_func(path)
  vim.api.nvim_put({ img_text }, "c", true, false)
end

return M

local log = require "obsidian.log"
local search = require "obsidian.search"

---@param data CommandArgs
return function(_, data)
  if not data.args or string.len(data.args) == 0 then
    local picker = Obsidian.picker
    if not picker then
      log.err "No picker configured"
      return
    end

    picker:find_notes()
  else
    search.resolve_note_async(data.args, function(note)
      note:open()
    end)
  end
end

local abc = require "obsidian.abc"
local async = require "plenary.async"
local channel = require("plenary.async.control").channel
local log = require "obsidian.log"
local util = require "obsidian.util"
local uv = vim.uv

local M = {}

---An abstract class that mimics Python's `concurrent.futures.Executor` class.
---@class obsidian.Executor : obsidian.ABC
---@field tasks_running integer
---@field tasks_pending integer
local Executor = abc.new_class()

---@return obsidian.Executor
Executor.new = function()
  local self = Executor.init()
  self.tasks_running = 0
  self.tasks_pending = 0
  return self
end

---Submit a one-off function with a callback for the executor to run.
---
---@param self obsidian.Executor
---@param fn function
---@param callback function|?
---@diagnostic disable-next-line: unused-local,unused-vararg
Executor.submit = function(self, fn, callback, ...)
  error "not implemented"
end

---Map a function over a generator or array of task args, or the keys and values in a regular table.
---The callback is called with an array of the results once all tasks have finished.
---The order of the results passed to the callback will be the same as the order of the corresponding task args.
---
---@param self obsidian.Executor
---@param fn function
---@param task_args table[]|table|function
---@param callback function|?
---@diagnostic disable-next-line: unused-local
Executor.map = function(self, fn, task_args, callback)
  local results = {}
  local num_tasks = 0
  local tasks_completed = 0
  local all_submitted = false
  local tx, rx = channel.oneshot()

  local function collect_results()
    rx()
    return results
  end

  local function get_task_done_fn(i)
    return function(...)
      tasks_completed = tasks_completed + 1
      results[i] = { ... }
      if all_submitted and tasks_completed == num_tasks then
        tx()
      end
    end
  end

  if type(task_args) == "table" and util.islist(task_args) then
    num_tasks = #task_args
    for i, args in ipairs(task_args) do
      if i == #task_args then
        all_submitted = true
      end
      if type(args) ~= "table" then
        args = { args }
      end
      self:submit(fn, get_task_done_fn(i), unpack(args))
    end
  elseif type(task_args) == "table" then
    num_tasks = vim.tbl_count(task_args)
    local i = 0
    for k, v in pairs(task_args) do
      i = i + 1
      if i == #task_args then
        all_submitted = true
      end
      self:submit(fn, get_task_done_fn(i), k, v)
    end
  elseif type(task_args) == "function" then
    local i = 0
    local args = { task_args() }
    local next_args = { task_args() }
    while args[1] ~= nil do
      if next_args[1] == nil then
        all_submitted = true
      end
      i = i + 1
      num_tasks = num_tasks + 1
      self:submit(fn, get_task_done_fn(i), unpack(args))
      args = next_args
      next_args = { task_args() }
    end
  else
    error(string.format("unexpected type '%s' for 'task_args'", type(task_args)))
  end

  if num_tasks == 0 then
    if callback ~= nil then
      callback {}
    end
  else
    async.run(collect_results, callback and callback or function(_) end)
  end
end

---@param self obsidian.Executor
---@param timeout integer|?
---@param pause_fn function(integer)
Executor._join = function(self, timeout, pause_fn)
  local start_time = uv.hrtime() / 1000000 -- ns -> ms
  local pause_for = 100
  if timeout ~= nil then
    pause_for = math.min(timeout / 2, pause_for)
  end
  while self.tasks_pending > 0 or self.tasks_running > 0 do
    pause_fn(pause_for)
    if timeout ~= nil and (uv.hrtime() / 1000000) - start_time > timeout then
      error "Timeout error from Executor.join()"
    end
  end
end

---Block Neovim until all currently running tasks have completed, waiting at most `timeout` milliseconds
---before raising a timeout error.
---
---This is useful in testing, but in general you want to avoid blocking Neovim.
---
---@param self obsidian.Executor
---@param timeout integer|?
Executor.join = function(self, timeout)
  self:_join(timeout, vim.wait)
end

---An async version of `.join()`.
---
---@param self obsidian.Executor
---@param timeout integer|?
Executor.join_async = function(self, timeout)
  self:_join(timeout, async.util.sleep)
end

---Run the callback when the executor finishes all tasks.
---@param self obsidian.Executor
---@param timeout integer|?
---@param callback function
Executor.join_and_then = function(self, timeout, callback)
  async.run(function()
    self:join_async(timeout)
  end, callback)
end

---An Executor that uses coroutines to run user functions concurrently.
---@class obsidian.AsyncExecutor : obsidian.Executor
---@field max_workers integer|?
---@field tasks_running integer
---@field tasks_pending integer
local AsyncExecutor = abc.new_class({
  __tostring = function(self)
    return string.format("AsyncExecutor(max_workers=%s)", self.max_workers)
  end,
}, Executor.new())

M.AsyncExecutor = AsyncExecutor

---@param max_workers integer|?
---@return obsidian.AsyncExecutor
AsyncExecutor.new = function(max_workers)
  local self = AsyncExecutor.init()
  if max_workers == nil then
    max_workers = 10
  elseif max_workers < 0 then
    max_workers = nil
  elseif max_workers == 0 then
    max_workers = 1
  end
  self.max_workers = max_workers
  self.tasks_running = 0
  self.tasks_pending = 0
  return self
end

---Submit a one-off function with a callback to the thread pool.
---
---@param self obsidian.AsyncExecutor
---@param fn function
---@param callback function|?
---@diagnostic disable-next-line: unused-local
AsyncExecutor.submit = function(self, fn, callback, ...)
  self.tasks_pending = self.tasks_pending + 1
  local args = { ... }
  async.run(function()
    if self.max_workers ~= nil then
      while self.tasks_running >= self.max_workers do
        async.util.sleep(20)
      end
    end
    self.tasks_pending = self.tasks_pending - 1
    self.tasks_running = self.tasks_running + 1
    return fn(unpack(args))
  end, function(...)
    self.tasks_running = self.tasks_running - 1
    if callback ~= nil then
      callback(...)
    end
  end)
end

---A multi-threaded Executor which uses the Libuv threadpool.
---@class obsidian.ThreadPoolExecutor : obsidian.Executor
---@field tasks_running integer
local ThreadPoolExecutor = abc.new_class({
  __tostring = function(self)
    return string.format("ThreadPoolExecutor(max_workers=%s)", self.max_workers)
  end,
}, Executor.new())

M.ThreadPoolExecutor = ThreadPoolExecutor

---@return obsidian.ThreadPoolExecutor
ThreadPoolExecutor.new = function()
  local self = ThreadPoolExecutor.init()
  self.tasks_running = 0
  self.tasks_pending = 0
  return self
end

---Submit a one-off function with a callback to the thread pool.
---
---@param self obsidian.ThreadPoolExecutor
---@param fn function
---@param callback function|?
---@diagnostic disable-next-line: unused-local
ThreadPoolExecutor.submit = function(self, fn, callback, ...)
  self.tasks_running = self.tasks_running + 1
  local ctx = uv.new_work(fn, function(...)
    self.tasks_running = self.tasks_running - 1
    if callback ~= nil then
      callback(...)
    end
  end)
  ctx:queue(...)
end

---@param cmds string[]
---@param on_stdout function|? (string) -> nil
---@param on_exit function|? (integer) -> nil
---@param sync boolean
local init_job = function(cmds, on_stdout, on_exit, sync)
  local stderr_lines = false

  local on_obj = function(obj)
    --- NOTE: commands like `rg` return a non-zero exit code when there are no matches, which is okay.
    --- So we only log no-zero exit codes as errors when there's also stderr lines.
    if obj.code > 0 and stderr_lines then
      log.err("Command '%s' exited with non-zero code %s. See logs for stderr.", cmds, obj.code)
    elseif stderr_lines then
      log.warn("Captured stderr output while running command '%s'. See logs for details.", cmds)
    end
    if on_exit ~= nil then
      on_exit(obj.code)
    end
  end

  on_stdout = util.buffer_fn(on_stdout)

  local function stdout(err, data)
    if err ~= nil then
      return log.err("Error running command '%s'\n:%s", cmds, err)
    end
    if data ~= nil then
      on_stdout(data)
    end
  end

  local function stderr(err, data)
    if err then
      return log.err("Error running command '%s'\n:%s", cmds, err)
    elseif data ~= nil then
      if not stderr_lines then
        log.err("Captured stderr output while running command '%s'", cmds)
        stderr_lines = true
      end
      log.err("[stderr] %s", data)
    end
  end

  return function()
    log.debug("Initializing job '%s'", cmds)

    if sync then
      local obj = vim.system(cmds, { stdout = stdout, stderr = stderr }):wait()
      on_obj(obj)
      return obj
    else
      vim.system(cmds, { stdout = stdout, stderr = stderr }, on_obj)
    end
  end
end

---@param cmds string[]
---@param on_stdout function|? (string) -> nil
---@param on_exit function|? (integer) -> nil
---@return integer exit_code
M.run_job = function(cmds, on_stdout, on_exit)
  local job = init_job(cmds, on_stdout, on_exit, true)
  return job().code
end

---@param cmds string[]
---@param on_stdout function|? (string) -> nil
---@param on_exit function|? (integer) -> nil
M.run_job_async = function(cmds, on_stdout, on_exit)
  local job = init_job(cmds, on_stdout, on_exit, false)
  job()
end

---@param fn function
---@param timeout integer (milliseconds)
M.throttle = function(fn, timeout)
  ---@type integer
  local last_call = 0
  ---@type uv.uv_timer_t?
  local timer = nil

  return function(...)
    if timer ~= nil then
      timer:stop()
    end

    local ms_remaining = timeout - (vim.uv.now() - last_call)

    if ms_remaining > 0 then
      if timer == nil then
        timer = assert(vim.uv.new_timer())
      end

      local args = { ... }

      timer:start(
        ms_remaining,
        0,
        vim.schedule_wrap(function()
          if timer ~= nil then
            timer:stop()
            timer:close()
            timer = nil
          end

          last_call = vim.uv.now()
          fn(unpack(args))
        end)
      )
    else
      last_call = vim.uv.now()
      fn(...)
    end
  end
end

---Run an async function in a non-async context. The async function is expected to take a single
---callback parameters with the results. This function returns those results.
---@param async_fn_with_callback function (function,) -> any
---@param timeout integer|?
---@return ...any results
M.block_on = function(async_fn_with_callback, timeout)
  local done = false
  local result
  timeout = timeout and timeout or 2000

  local function collect_result(...)
    result = { ... }
    done = true
  end

  async_fn_with_callback(collect_result)

  vim.wait(timeout, function()
    return done
  end, 20, false)

  return unpack(result)
end

return M

local api = require "obsidian.api"
local Path = require "obsidian.path"
local search = require "obsidian.search"
local log = require "obsidian.log"

---@param path? string|obsidian.Path
local function open_in_app(path)
  local vault_name = vim.fs.basename(tostring(Obsidian.workspace.root))
  if not path then
    return Obsidian.opts.open.func("obsidian://open?vault=" .. vim.uri_encode(vault_name))
  end
  path = tostring(path)
  local this_os = api.get_os()

  -- Normalize path for windows.
  if this_os == api.OSType.Windows then
    path = string.gsub(path, "/", "\\")
  end

  local encoded_vault = vim.uri_encode(vault_name)
  local encoded_path = vim.uri_encode(path)

  local uri
  if Obsidian.opts.open.use_advanced_uri then
    local line = vim.api.nvim_win_get_cursor(0)[1] or 1
    uri = ("obsidian://advanced-uri?vault=%s&filepath=%s&line=%i"):format(encoded_vault, encoded_path, line)
  else
    uri = ("obsidian://open?vault=%s&file=%s"):format(encoded_vault, encoded_path)
  end
  print(uri)

  Obsidian.opts.open.func(uri)
end

---@param data CommandArgs
return function(_, data)
  ---@type string|?
  local search_term

  if data.args and data.args:len() > 0 then
    search_term = data.args
  else
    -- Check for a note reference under the cursor.
    local link_string, _ = api.cursor_link()
    search_term = link_string
  end

  if search_term then
    search.resolve_link_async(search_term, function(results)
      if vim.tbl_isempty(results) then
        return log.err "Note under cusros is not resolved"
      end
      vim.schedule(function()
        open_in_app(results[1].path)
      end)
    end)
  else
    -- Otherwise use the path of the current buffer.
    local bufname = vim.api.nvim_buf_get_name(0)
    local path = Path.new(bufname):vault_relative_path()
    open_in_app(path)
  end
end

local log = require "obsidian.log"
local search = require "obsidian.search"
local api = require "obsidian.api"

return function()
  local picker = Obsidian.picker
  if not picker then
    return log.err "No picker configured"
  end

  local note = api.current_note(0)
  assert(note, "not in a note")

  search.find_links(note, {}, function(entries)
    entries = vim.tbl_map(function(match)
      return match.link
    end, entries)

    -- Launch picker.
    picker:pick(entries, {
      prompt_title = "Links",
      callback = function(link)
        api.follow_link(link)
      end,
    })
  end)
end

local M = {}

-- Ran once on the plugin startup
---@param opts obsidian.config.ClientOpts
function M.register_sources(opts)
  local cmp = require "cmp"

  cmp.register_source("obsidian", require("obsidian.completion.sources.nvim_cmp.refs").new())
  cmp.register_source("obsidian_tags", require("obsidian.completion.sources.nvim_cmp.tags").new())
  if opts.completion.create_new then
    cmp.register_source("obsidian_new", require("obsidian.completion.sources.nvim_cmp.new").new())
  end
end

-- Triggered for each opened markdown buffer that's in a workspace and configures nvim_cmp sources for the current buffer.
---@param opts obsidian.config.ClientOpts
function M.inject_sources(opts)
  local cmp = require "cmp"

  local sources = {
    { name = "obsidian" },
    { name = "obsidian_tags" },
  }
  if opts.completion.create_new then
    table.insert(sources, { name = "obsidian_new" })
  end
  for _, source in pairs(cmp.get_config().sources) do
    if source.name ~= "obsidian" and source.name ~= "obsidian_new" and source.name ~= "obsidian_tags" then
      table.insert(sources, source)
    end
  end
  ---@diagnostic disable-next-line: missing-fields
  cmp.setup.buffer { sources = sources }
end

return M

local Path = require "obsidian.path"
local Note = require "obsidian.note"
local AsyncExecutor = require("obsidian.async").AsyncExecutor
local log = require "obsidian.log"
local search = require "obsidian.search"
local util = require "obsidian.util"
local compat = require "obsidian.compat"
local api = require "obsidian.api"
local enumerate, zip = util.enumerate, util.zip

local resolve_note = function(query, opts)
  opts = opts or {}
  return require("obsidian.async").block_on(function(cb)
    print(query)
    return search.resolve_note_async(query, cb, { notes = opts.notes })
  end, 5000)
end

---@param data CommandArgs
return function(_, data)
  -- Resolve the note to rename.
  ---@type boolean
  local is_current_buf
  ---@type integer|?
  local cur_note_bufnr
  ---@type obsidian.Path
  local cur_note_path
  ---@type obsidian.Note
  local cur_note, cur_note_id

  local cur_link = api.cursor_link()
  if not cur_link then
    -- rename current note
    is_current_buf = true
    cur_note_bufnr = assert(vim.fn.bufnr())
    cur_note_path = Path.buffer(cur_note_bufnr)
    cur_note = Note.from_file(cur_note_path)
    cur_note_id = tostring(cur_note.id)
  else
    -- rename note under the cursor
    local link_id = util.parse_link(cur_link)
    local notes = { resolve_note(link_id) }
    if #notes == 0 then
      log.err("Failed to resolve '%s' to a note", cur_link)
      return
    elseif #notes > 1 then
      log.err("Failed to resolve '%s' to a single note, found %d matches", cur_link, #notes)
      return
    else
      cur_note = notes[1]
    end

    is_current_buf = false
    cur_note_id = tostring(cur_note.id)
    cur_note_path = cur_note.path
    for bufnr, bufpath in api.get_named_buffers() do
      if bufpath == cur_note_path then
        cur_note_bufnr = bufnr
        break
      end
    end
  end

  -- Validate args.
  local dry_run = false
  ---@type string|?
  local arg

  if data.args == "--dry-run" then
    dry_run = true
    data.args = nil
  end

  if data.args ~= nil and string.len(data.args) > 0 then
    arg = vim.trim(data.args)
  else
    arg = api.input("Enter new note ID/name/path: ", { completion = "file", default = cur_note_id })
    if not arg or string.len(arg) == 0 then
      log.warn "Rename aborted"
      return
    end
  end

  if vim.endswith(arg, " --dry-run") then
    dry_run = true
    arg = vim.trim(string.sub(arg, 1, -string.len " --dry-run" - 1))
  end

  assert(cur_note_path)
  local dirname = assert(cur_note_path:parent(), string.format("failed to resolve parent of '%s'", cur_note_path))

  -- Parse new note ID / path from args.
  local parts = vim.split(arg, "/", { plain = true })
  local new_note_id = parts[#parts]
  if new_note_id == "" then
    log.err "Invalid new note ID"
    return
  elseif vim.endswith(new_note_id, ".md") then
    new_note_id = string.sub(new_note_id, 1, -4)
  end

  ---@type obsidian.Path
  local new_note_path
  if #parts > 1 then
    parts[#parts] = nil
    new_note_path = Obsidian.dir:joinpath(unpack(compat.flatten { parts, new_note_id })):with_suffix ".md"
  else
    new_note_path = (dirname / new_note_id):with_suffix ".md"
  end

  if new_note_id == cur_note_id then
    log.warn "New note ID is the same, doing nothing"
    return
  end

  -- Get confirmation before continuing.
  local confirmation
  if not dry_run then
    confirmation = api.confirm(
      "Renaming '"
        .. cur_note_id
        .. "' to '"
        .. new_note_id
        .. "'...\n"
        .. "This will write all buffers and potentially modify a lot of files. If you're using version control "
        .. "with your vault it would be a good idea to commit the current state of your vault before running this.\n"
        .. "You can also do a dry run of this by running ':Obsidian rename "
        .. arg
        .. " --dry-run'.\n"
        .. "Do you want to continue?"
    )
  else
    confirmation = api.confirm(
      "Dry run: renaming '" .. cur_note_id .. "' to '" .. new_note_id .. "'...\n" .. "Do you want to continue?"
    )
  end

  if not confirmation then
    log.warn "Rename aborted"
    return
  end

  ---@param fn function
  local function quietly(fn, ...)
    local ok, res = pcall(fn, ...)
    if not ok then
      error(res)
    end
  end

  -- Write all buffers.
  quietly(vim.cmd.wall)

  -- Rename the note file and remove or rename the corresponding buffer, if there is one.
  if cur_note_bufnr ~= nil then
    if is_current_buf then
      -- If we're renaming the note of a current buffer, save as the new path.
      if not dry_run then
        quietly(vim.cmd.saveas, tostring(new_note_path))
        local new_bufnr_current_note = vim.fn.bufnr(tostring(cur_note_path))
        quietly(vim.cmd.bdelete, new_bufnr_current_note)
        vim.fn.delete(tostring(cur_note_path))
      else
        log.info("Dry run: saving current buffer as '" .. tostring(new_note_path) .. "' and removing old file")
      end
    else
      -- For the non-current buffer the best we can do is delete the buffer (we've already saved it above)
      -- and then make a file-system call to rename the file.
      if not dry_run then
        quietly(vim.cmd.bdelete, cur_note_bufnr)
        cur_note_path:rename(new_note_path)
      else
        log.info(
          "Dry run: removing buffer '"
            .. tostring(cur_note_path)
            .. "' and renaming file to '"
            .. tostring(new_note_path)
            .. "'"
        )
      end
    end
  else
    -- When the note is not loaded into a buffer we just need to rename the file.
    if not dry_run then
      cur_note_path:rename(new_note_path)
    else
      log.info("Dry run: renaming file '" .. tostring(cur_note_path) .. "' to '" .. tostring(new_note_path) .. "'")
    end
  end

  -- We need to update its frontmatter note_id
  -- to account for the rename.
  cur_note.id = new_note_id
  cur_note.path = Path.new(new_note_path)
  if not dry_run then
    cur_note:save()
  else
    log.info("Dry run: updating frontmatter of '" .. tostring(new_note_path) .. "'")
  end

  local cur_note_rel_path = assert(cur_note_path:vault_relative_path { strict = true })
  local new_note_rel_path = assert(new_note_path:vault_relative_path { strict = true })

  -- Search notes on disk for any references to `cur_note_id`.
  -- We look for the following forms of references:
  -- * '[[cur_note_id]]'
  -- * '[[cur_note_id|ALIAS]]'
  -- * '[[cur_note_id\|ALIAS]]' (a wiki link within a table)
  -- * '[ALIAS](cur_note_id)'
  -- And all of the above with relative paths (from the vault root) to the note instead of just the note ID,
  -- with and without the ".md" suffix.
  -- Another possible form is [[ALIAS]], but we don't change the note's aliases when renaming
  -- so those links will still be valid.
  ---@param ref_link string
  ---@return string[]
  local function get_ref_forms(ref_link)
    return {
      "[[" .. ref_link .. "]]",
      "[[" .. ref_link .. "|",
      "[[" .. ref_link .. "\\|",
      "[[" .. ref_link .. "#",
      "](" .. ref_link .. ")",
      "](" .. ref_link .. "#",
    }
  end

  local reference_forms = compat.flatten {
    get_ref_forms(cur_note_id),
    get_ref_forms(cur_note_rel_path),
    get_ref_forms(string.sub(cur_note_rel_path, 1, -4)),
  }
  local replace_with = compat.flatten {
    get_ref_forms(new_note_id),
    get_ref_forms(new_note_rel_path),
    get_ref_forms(string.sub(new_note_rel_path, 1, -4)),
  }

  local executor = AsyncExecutor.new()

  local file_count = 0
  local replacement_count = 0
  local all_tasks_submitted = false

  ---@param path string
  ---@return integer
  local function replace_refs(path)
    --- Read lines, replacing refs as we go.
    local count = 0
    local lines = {}
    local f = io.open(path, "r")
    assert(f)
    for line_num, line in enumerate(f:lines "*L") do
      for ref, replacement in zip(reference_forms, replace_with) do
        local n
        line, n = string.gsub(line, vim.pesc(ref), replacement)
        if dry_run and n > 0 then
          log.info(
            "Dry run: '"
              .. tostring(path)
              .. "':"
              .. line_num
              .. " Replacing "
              .. n
              .. " occurrence(s) of '"
              .. ref
              .. "' with '"
              .. replacement
              .. "'"
          )
        end
        count = count + n
      end
      lines[#lines + 1] = line
    end
    f:close()

    --- Write the new lines back.
    if not dry_run and count > 0 then
      f = io.open(path, "w")
      assert(f)
      f:write(unpack(lines))
      f:close()
    end

    return count
  end

  local function on_search_match(match)
    local path = tostring(Path.new(match.path.text):resolve { strict = true })
    file_count = file_count + 1
    executor:submit(replace_refs, function(count)
      replacement_count = replacement_count + count
    end, path)
  end

  search.search_async(
    Obsidian.dir,
    reference_forms,
    { fixed_strings = true, max_count_per_file = 1 },
    on_search_match,
    function(_)
      all_tasks_submitted = true
    end
  )

  -- Wait for all tasks to get submitted.
  vim.wait(2000, function()
    return all_tasks_submitted
  end, 50, false)

  -- Then block until all tasks are finished.
  executor:join(2000)

  local prefix = dry_run and "Dry run: replaced " or "Replaced "
  log.info(prefix .. replacement_count .. " reference(s) across " .. file_count .. " file(s)")

  -- In case the files of any current buffers were changed.
  vim.cmd.checktime()
end

local log = require "obsidian.log"

---@param data CommandArgs
return function(_, data)
  local picker = Obsidian.picker
  if not picker then
    log.err "No picker configured"
    return
  end
  picker:grep_notes { query = data.args }
end

local Note = require "obsidian.note"
local Patterns = require("obsidian.search").Patterns

local M = {}

---@type { pattern: string, offset: integer }[]
local TAG_PATTERNS = {
  { pattern = "[%s%(]#" .. Patterns.TagCharsOptional .. "$", offset = 2 },
  { pattern = "^#" .. Patterns.TagCharsOptional .. "$", offset = 1 },
}

M.find_tags_start = function(input)
  for _, pattern in ipairs(TAG_PATTERNS) do
    local match = string.match(input, pattern.pattern)
    if match then
      return string.sub(match, pattern.offset + 1)
    end
  end
end

--- Find the boundaries of the YAML frontmatter within the buffer.
---@param bufnr integer
---@return integer|?, integer|?
local get_frontmatter_boundaries = function(bufnr)
  local note = Note.from_buffer(bufnr)
  if note.frontmatter_end_line ~= nil then
    return 1, note.frontmatter_end_line
  end
end

---@return boolean, string|?, boolean|?
M.can_complete = function(request)
  local search = M.find_tags_start(request.context.cursor_before_line)
  if not search or string.len(search) == 0 then
    return false
  end

  -- Check if we're inside frontmatter.
  local in_frontmatter = false
  local line = request.context.cursor.line
  local frontmatter_start, frontmatter_end = get_frontmatter_boundaries(request.context.bufnr)
  if
    frontmatter_start ~= nil
    and frontmatter_start <= (line + 1)
    and frontmatter_end ~= nil
    and line <= frontmatter_end
  then
    in_frontmatter = true
  end

  return true, search, in_frontmatter
end

M.get_trigger_characters = function()
  return { "#" }
end

M.get_keyword_pattern = function()
  -- Note that this is a vim pattern, not a Lua pattern. See ':help pattern'.
  -- The enclosing [=[ ... ]=] is just a way to mark the boundary of a
  -- string in Lua.
  -- return [=[\%(^\|[^#]\)\zs#[a-zA-Z0-9_/-]\+]=]
  return "#[a-zA-Z0-9_/-]\\+"
end

return M

local util = require "obsidian.util"

local M = {}

---@enum obsidian.completion.RefType
M.RefType = {
  Wiki = 1,
  Markdown = 2,
}

---Backtrack through a string to find the first occurrence of '[['.
---
---@param input string
---@return string|?, string|?, obsidian.completion.RefType|?
local find_search_start = function(input)
  for i = string.len(input), 1, -1 do
    local substr = string.sub(input, i)
    if vim.startswith(substr, "]") or vim.endswith(substr, "]") then
      return nil
    elseif vim.startswith(substr, "[[") then
      return substr, string.sub(substr, 3)
    elseif vim.startswith(substr, "[") and string.sub(input, i - 1, i - 1) ~= "[" then
      return substr, string.sub(substr, 2)
    end
  end
  return nil
end

---Check if a completion request can/should be carried out. Returns a boolean
---and, if true, the search string and the column indices of where the completion
---items should be inserted.
---
---@return boolean, string|?, integer|?, integer|?, obsidian.completion.RefType|?
M.can_complete = function(request)
  local input, search = find_search_start(request.context.cursor_before_line)
  if input == nil or search == nil then
    return false
  elseif string.len(search) == 0 or util.is_whitespace(search) then
    return false
  end

  if vim.startswith(input, "[[") then
    local suffix = string.sub(request.context.cursor_after_line, 1, 2)
    local cursor_col = request.context.cursor.col
    local insert_end_offset = suffix == "]]" and 1 or -1
    return true, search, cursor_col - 1 - #input, cursor_col + insert_end_offset, M.RefType.Wiki
  elseif vim.startswith(input, "[") then
    local suffix = string.sub(request.context.cursor_after_line, 1, 1)
    local cursor_col = request.context.cursor.col
    local insert_end_offset = suffix == "]" and 0 or -1
    return true, search, cursor_col - 1 - #input, cursor_col + insert_end_offset, M.RefType.Markdown
  else
    return false
  end
end

M.get_trigger_characters = function()
  return { "[" }
end

M.get_keyword_pattern = function()
  -- Note that this is a vim pattern, not a Lua pattern. See ':help pattern'.
  -- The enclosing [=[ ... ]=] is just a way to mark the boundary of a
  -- string in Lua.
  return [=[\%(^\|[^\[]\)\zs\[\{1,2}[^\]]\+\]\{,2}]=]
end

return M

---@class obsidian.Logger
local log = {}

log._log_level = vim.log.levels.INFO

---@param t table
---@return boolean
local function has_tostring(t)
  local mt = getmetatable(t)
  return mt ~= nil and mt.__tostring ~= nil
end

---@param msg string
---@return any[]
local function message_args(msg, ...)
  local args = { ... }
  local num_directives = select(2, string.gsub(msg, "%%", "")) - 2 * select(2, string.gsub(msg, "%%%%", ""))

  -- Some elements might be nil, so we can't use 'ipairs'.
  local out = {}
  for i = 1, #args do
    local v = args[i]
    if v == nil then
      out[i] = tostring(v)
    elseif type(v) == "table" and not has_tostring(v) then
      out[i] = vim.inspect(v)
    else
      out[i] = v
    end
  end

  -- If were short formatting args relative to the number of directives, add "nil" strings on.
  if #out < num_directives then
    for i = #out + 1, num_directives do
      out[i] = "nil"
    end
  end

  return out
end

---@param level integer
log.set_level = function(level)
  log._log_level = level
end

--- Log a message.
---
---@param msg any
---@param level integer|?
log.log = function(msg, level, ...)
  if level == nil or log._log_level == nil or level >= log._log_level then
    msg = string.format(tostring(msg), unpack(message_args(msg, ...)))
    if vim.in_fast_event() then
      vim.schedule(function()
        vim.notify(msg, level, { title = "Obsidian.nvim" })
      end)
    else
      vim.notify(msg, level, { title = "Obsidian.nvim" })
    end
  end
end

---Log a message only once.
---
---@param msg any
---@param level integer|?
log.log_once = function(msg, level, ...)
  if level == nil or log._log_level == nil or level >= log._log_level then
    msg = string.format(tostring(msg), unpack(message_args(msg, ...)))
    if vim.in_fast_event() then
      vim.schedule(function()
        vim.notify_once(msg, level, { title = "Obsidian.nvim" })
      end)
    else
      vim.notify_once(msg, level, { title = "Obsidian.nvim" })
    end
  end
end

---@param msg string
log.debug = function(msg, ...)
  log.log(msg, vim.log.levels.DEBUG, ...)
end

---@param msg string
log.info = function(msg, ...)
  log.log(msg, vim.log.levels.INFO, ...)
end

---@param msg string
log.warn = function(msg, ...)
  log.log(msg, vim.log.levels.WARN, ...)
end

---@param msg string
log.warn_once = function(msg, ...)
  log.log_once(msg, vim.log.levels.WARN, ...)
end

---@param msg string
log.err = function(msg, ...)
  log.log(msg, vim.log.levels.ERROR, ...)
end

log.error = log.err

---@param msg string
log.err_once = function(msg, ...)
  log.log_once(msg, vim.log.levels.ERROR, ...)
end

log.error_once = log.err

return log

---builtin functions that are default values for config options
local M = {}
local util = require "obsidian.util"

---Create a new unique Zettel ID.
---
---@return string
M.zettel_id = function()
  local suffix = ""
  for _ = 1, 4 do
    suffix = suffix .. string.char(math.random(65, 90))
  end
  return tostring(os.time()) .. "-" .. suffix
end

---@param opts { path: string, label: string, id: string|integer|?, anchor: obsidian.note.HeaderAnchor|?, block: obsidian.note.Block|? }
---@return string
M.wiki_link_alias_only = function(opts)
  ---@type string
  local header_or_block = ""
  if opts.anchor then
    header_or_block = string.format("#%s", opts.anchor.header)
  elseif opts.block then
    header_or_block = string.format("#%s", opts.block.id)
  end
  return string.format("[[%s%s]]", opts.label, header_or_block)
end

---@param opts { path: string, label: string, id: string|integer|?, anchor: obsidian.note.HeaderAnchor|?, block: obsidian.note.Block|? }
---@return string
M.wiki_link_path_only = function(opts)
  ---@type string
  local header_or_block = ""
  if opts.anchor then
    header_or_block = opts.anchor.anchor
  elseif opts.block then
    header_or_block = string.format("#%s", opts.block.id)
  end
  return string.format("[[%s%s]]", opts.path, header_or_block)
end

---@param opts { path: string, label: string, id: string|integer|?, anchor: obsidian.note.HeaderAnchor|?, block: obsidian.note.Block|? }
---@return string
M.wiki_link_path_prefix = function(opts)
  local anchor = ""
  local header = ""
  if opts.anchor then
    anchor = opts.anchor.anchor
    header = util.format_anchor_label(opts.anchor)
  elseif opts.block then
    anchor = "#" .. opts.block.id
    header = "#" .. opts.block.id
  end

  if opts.label ~= opts.path then
    return string.format("[[%s%s|%s%s]]", opts.path, anchor, opts.label, header)
  else
    return string.format("[[%s%s]]", opts.path, anchor)
  end
end

---@param opts { path: string, label: string, id: string|integer|?, anchor: obsidian.note.HeaderAnchor|?, block: obsidian.note.Block|? }
---@return string
M.wiki_link_id_prefix = function(opts)
  local anchor = ""
  local header = ""
  if opts.anchor then
    anchor = opts.anchor.anchor
    header = util.format_anchor_label(opts.anchor)
  elseif opts.block then
    anchor = "#" .. opts.block.id
    header = "#" .. opts.block.id
  end

  if opts.id == nil then
    return string.format("[[%s%s]]", opts.label, anchor)
  elseif opts.label ~= opts.id then
    return string.format("[[%s%s|%s%s]]", opts.id, anchor, opts.label, header)
  else
    return string.format("[[%s%s]]", opts.id, anchor)
  end
end

---@param opts { path: string, label: string, id: string|integer|?, anchor: obsidian.note.HeaderAnchor|?, block: obsidian.note.Block|? }
---@return string
M.markdown_link = function(opts)
  local anchor = ""
  local header = ""
  if opts.anchor then
    anchor = opts.anchor.anchor
    header = util.format_anchor_label(opts.anchor)
  elseif opts.block then
    anchor = "#" .. opts.block.id
    header = "#" .. opts.block.id
  end

  local path = util.urlencode(opts.path, { keep_path_sep = true })
  return string.format("[%s%s](%s%s)", opts.label, header, path, anchor)
end

---@param path string
---@return string
M.img_text_func = function(path)
  local format_string = {
    markdown = "![](%s)",
    wiki = "![[%s]]",
  }
  local style = Obsidian.opts.preferred_link_style
  local name = vim.fs.basename(tostring(path))

  if style == "markdown" then
    name = require("obsidian.util").urlencode(name)
  end

  return string.format(format_string[style], name)
end

return M

-- Useful type definitions go here.

---@class CommandArgs
---The table passed to user commands.
---For details see `:help nvim_create_user_command()` and the command attribute docs
---
---@field name string Command name
---@field args string The args passed to the command, if any <args>
---@field fargs table The args split by unescaped whitespace (when more than one argument is allowed), if any <f-args>
---@field nargs string Number of arguments |:command-nargs|
---@field bang boolean "true" if the command was executed with a ! modifier <bang>
---@field line1 number The starting line of the command range <line1>
---@field line2 number The final line of the command range <line2>
---@field range number The number of items in the command range: 0, 1, or 2 <range>
---@field count number Any count supplied <count>
---@field reg string The optional register, if specified <reg>
---@field mods string Command modifiers, if any <mods>
---@field smods table Command modifiers in a structured format. Has the same structure as the "mods" key of
---@field raw_print boolean HACK: for debug command and info

---@class obsidian.InsertTemplateContext
---The table passed to user substitution functions when inserting templates into a buffer.
---
---@field type "insert_template"
---@field template_name string|obsidian.Path The name or path of the template being used.
---@field template_opts obsidian.config.TemplateOpts The template options being used.
---@field templates_dir obsidian.Path The folder containing the template file.
---@field location [number, number, number, number] `{ buf, win, row, col }` location from which the request was made.
---@field partial_note? obsidian.Note An optional note with fields to copy from.

---@class obsidian.CloneTemplateContext
---The table passed to user substitution functions when cloning template files to create new notes.
---
---@field type "clone_template"
---@field template_name string|obsidian.Path The name or path of the template being used.
---@field template_opts obsidian.config.TemplateOpts The template options being used.
---@field templates_dir obsidian.Path The folder containing the template file.
---@field destination_path obsidian.Path The path the cloned template will be written to.
---@field partial_note obsidian.Note The note being written.

---@alias obsidian.TemplateContext obsidian.InsertTemplateContext | obsidian.CloneTemplateContext
---The table passed to user substitution functions. Use `ctx.type` to distinguish between the different kinds.

local Note = require "obsidian.note"
local Path = require "obsidian.path"
local log = require "obsidian.log"
local api = require "obsidian.api"
local iter = vim.iter

return function()
  local start_time = vim.uv.hrtime()
  local count = 0
  local errors = {}
  local warnings = {}

  for path in api.dir(Obsidian.dir) do
    local relative_path = Path.new(path):vault_relative_path { strict = true }
    local ok, res = pcall(Note.from_file, path)

    if not ok then
      errors[#errors + 1] = string.format("Failed to parse note '%s': ", relative_path, res)
    elseif res.has_frontmatter == false then
      warnings[#warnings + 1] = string.format("'%s' missing frontmatter", relative_path)
    end
    count = count + 1
  end

  local runtime = math.floor((vim.uv.hrtime() - start_time) / 1000000)
  local messages = { "Checked " .. tostring(count) .. " notes in " .. runtime .. "ms" }
  local log_level = vim.log.levels.INFO

  if #warnings > 0 then
    messages[#messages + 1] = "\nThere were " .. tostring(#warnings) .. " warning(s):"
    log_level = vim.log.levels.WARN
    for warning in iter(warnings) do
      messages[#messages + 1] = "  " .. warning
    end
  end

  if #errors > 0 then
    messages[#messages + 1] = "\nThere were " .. tostring(#errors) .. " error(s):"
    for err in iter(errors) do
      messages[#messages + 1] = "  " .. err
    end
    log_level = vim.log.levels.ERROR
  end

  log.log(table.concat(messages, "\n"), log_level)
end

local M = {}

---@class obsidian.ABC
---@field init function an init function which essentially calls 'setmetatable({}, mt)'.
---@field as_tbl function get a raw table with only the instance's fields
---@field mt table the metatable

---Create a new class.
---
---This handles the boilerplate of setting up metatables correctly and consistently when defining new classes,
---and comes with some better default metamethods, like '.__eq' which will recursively compare all fields
---that don't start with a double underscore.
---
---When you use this you should call `.init()` within your constructors instead of `setmetatable()`.
---For example:
---
---```lua
---    local Foo = new_class()
---
---    Foo.new = function(x)
---      local self = Foo.init()
---      self.x = x
---      return self
---    end
---```
---
---The metatable for classes created this way is accessed with the field `.mt`. This way you can easily
---add/override metamethods to your classes. Continuing the example above:
---
---```lua
---    Foo.mt.__tostring = function(self)
---      return string.format("Foo(%d)", self.x)
---    end
---
---    local foo = Foo.new(1)
---    print(foo)
---```
---
---Alternatively you can pass metamethods directly to 'new_class()'. For example:
---
---```lua
---    local Foo = new_class {
---      __tostring = function(self)
---        return string.format("Foo(%d)", self.x)
---      end,
---    }
---```
---
---@param metamethods table|? {string, function} - Metamethods
---@param base_class table|? A base class to start from
---@return obsidian.ABC
M.new_class = function(metamethods, base_class)
  local class = base_class and base_class or {}

  -- Metatable for the class so that all instances have the same metatable.
  class.mt = vim.tbl_extend("force", {
    __index = class,
    __eq = function(a, b)
      -- In order to use 'vim.deep_equal' we need to pull out the raw fields first.
      -- If we passed 'a' and 'b' directly to 'vim.deep_equal' we'd get a stack overflow due
      -- to infinite recursion, since 'vim.deep_equal' calls the '.__eq' metamethod.
      local a_fields = a:as_tbl()
      local b_fields = b:as_tbl()
      return vim.deep_equal(a_fields, b_fields)
    end,
  }, metamethods and metamethods or {})

  class.init = function(t)
    local self = setmetatable(t and t or {}, class.mt)
    return self
  end

  class.as_tbl = function(self)
    local fields = {}
    for k, v in pairs(self) do
      if not vim.startswith(k, "__") then
        fields[k] = v
      end
    end
    return fields
  end

  return class
end

return M

local util = require "obsidian.util"
local parser = require "obsidian.yaml.parser"

local yaml = {}

---Deserialize a YAML string.
---@param str string
---@return any
yaml.loads = function(str)
  return parser.loads(str)
end

---@param s string
---@return boolean
local should_quote = function(s)
  -- TODO: this probably doesn't cover all edge cases.
  -- See https://www.yaml.info/learn/quote.html
  -- Check if it starts with a special character.
  if string.match(s, [[^["'\\[{&!-].*]]) then
    return true
  -- Check if it has a colon followed by whitespace.
  elseif string.find(s, ": ", 1, true) then
    return true
  -- Check if it's an empty string.
  elseif s == "" or string.match(s, "^[%s]+$") then
    return true
  else
    return false
  end
end

---@return string[]
local dumps
dumps = function(x, indent, order)
  local indent_str = string.rep(" ", indent)

  if type(x) == "string" then
    if should_quote(x) then
      x = string.gsub(x, '"', '\\"')
      return { indent_str .. [["]] .. x .. [["]] }
    else
      return { indent_str .. x }
    end
  end

  if type(x) == "boolean" then
    return { indent_str .. tostring(x) }
  end

  if type(x) == "number" then
    return { indent_str .. tostring(x) }
  end

  if type(x) == "table" then
    local out = {}

    if util.islist(x) then
      for _, v in ipairs(x) do
        local item_lines = dumps(v, indent + 2)
        table.insert(out, indent_str .. "- " .. util.lstrip_whitespace(item_lines[1]))
        for i = 2, #item_lines do
          table.insert(out, item_lines[i])
        end
      end
    else
      -- Gather and sort keys so we can keep the order deterministic.
      local keys = {}
      for k, _ in pairs(x) do
        table.insert(keys, k)
      end
      table.sort(keys, order)
      for _, k in ipairs(keys) do
        local v = x[k]
        if type(v) == "string" or type(v) == "boolean" or type(v) == "number" then
          table.insert(out, indent_str .. tostring(k) .. ": " .. dumps(v, 0)[1])
        elseif type(v) == "table" and vim.tbl_isempty(v) then
          table.insert(out, indent_str .. tostring(k) .. ": []")
        else
          local item_lines = dumps(v, indent + 2)
          table.insert(out, indent_str .. tostring(k) .. ":")
          for _, line in ipairs(item_lines) do
            table.insert(out, line)
          end
        end
      end
    end

    return out
  end

  error("Can't convert object with type " .. type(x) .. " to YAML")
end

---Dump an object to YAML lines.
---@param x any
---@param order function
---@return string[]
yaml.dumps_lines = function(x, order)
  return dumps(x, 0, order)
end

---Dump an object to a YAML string.
---@param x any
---@param order function|?
---@return string
yaml.dumps = function(x, order)
  return table.concat(dumps(x, 0, order), "\n")
end

return yaml

local Path = require "obsidian.path"
local Note = require "obsidian.note"
local util = require "obsidian.util"

--- Get the path to a daily note.
---
---@param datetime integer|?
---
---@return obsidian.Path, string (Path, ID) The path and ID of the note.
local daily_note_path = function(datetime)
  datetime = datetime and datetime or os.time()

  ---@type obsidian.Path
  local path = Path:new(Obsidian.dir)

  local options = Obsidian.opts

  if options.daily_notes.folder ~= nil then
    ---@type obsidian.Path
    ---@diagnostic disable-next-line: assign-type-mismatch
    path = path / options.daily_notes.folder
  elseif options.notes_subdir ~= nil then
    ---@type obsidian.Path
    ---@diagnostic disable-next-line: assign-type-mismatch
    path = path / options.notes_subdir
  end

  local id
  if options.daily_notes.date_format ~= nil then
    id = tostring(os.date(options.daily_notes.date_format, datetime))
  else
    id = tostring(os.date("%Y-%m-%d", datetime))
  end

  path = path / (id .. ".md")

  -- ID may contain additional path components, so make sure we use the stem.
  id = path.stem

  return path, id
end

--- Open (or create) the daily note.
---
---@param datetime integer
---@param opts { no_write: boolean|?, load: obsidian.note.LoadOpts|? }|?
---
---@return obsidian.Note
---
local _daily = function(datetime, opts)
  opts = opts or {}

  local path, id = daily_note_path(datetime)

  local options = Obsidian.opts

  ---@type string|?
  local alias
  if options.daily_notes.alias_format ~= nil then
    alias = tostring(os.date(options.daily_notes.alias_format, datetime))
  end

  ---@type obsidian.Note
  local note
  if path:exists() then
    note = Note.from_file(path, opts.load)
  else
    note = Note.create {
      id = id,
      aliases = {},
      tags = options.daily_notes.default_tags or {},
      dir = path:parent(),
    }

    if alias then
      note:add_alias(alias)
      note.title = alias
    end

    if not opts.no_write then
      note:write { template = options.daily_notes.template }
    end
  end

  return note
end

--- Open (or create) the daily note for today.
---
---@return obsidian.Note
local today = function()
  return _daily(os.time(), {})
end

--- Open (or create) the daily note from the last day.
---
---@return obsidian.Note
local yesterday = function()
  local now = os.time()
  local yesterday

  if Obsidian.opts.daily_notes.workdays_only then
    yesterday = util.working_day_before(now)
  else
    yesterday = util.previous_day(now)
  end

  return _daily(yesterday, {})
end

--- Open (or create) the daily note for the next day.
---
---@return obsidian.Note
local tomorrow = function()
  local now = os.time()
  local tomorrow

  if Obsidian.opts.daily_notes.workdays_only then
    tomorrow = util.working_day_after(now)
  else
    tomorrow = util.next_day(now)
  end

  return _daily(tomorrow, {})
end

--- Open (or create) the daily note for today + `offset_days`.
---
---@param offset_days integer|?
---@param opts { no_write: boolean|?, load: obsidian.note.LoadOpts|? }|?
---
---@return obsidian.Note
local daily = function(offset_days, opts)
  return _daily(os.time() + (offset_days * 3600 * 24), opts)
end

return {
  daily_note_path = daily_note_path,
  daily = daily,
  tomorrow = tomorrow,
  yesterday = yesterday,
  today = today,
}

local log = require "obsidian.log"
local api = require "obsidian.api"
local Note = require "obsidian.note"

---Extract the selected text into a new note
---and replace the selection with a link to the new note.
---
---@param data CommandArgs
return function(_, data)
  local viz = api.get_visual_selection()
  if not viz then
    log.err "Obsidian extract_note must be called with visual selection"
    return
  end

  local content = vim.split(viz.selection, "\n", { plain = true })

  ---@type string|?
  local title
  if data.args ~= nil and string.len(data.args) > 0 then
    title = vim.trim(data.args)
  else
    title = api.input "Enter title (optional): "
    if not title then
      log.warn "Aborted"
      return
    elseif title == "" then
      title = nil
    end
  end

  -- create the new note.
  local note = Note.create { title = title }

  -- replace selection with link to new note
  local link = api.format_link(note)
  vim.api.nvim_buf_set_text(0, viz.csrow - 1, viz.cscol - 1, viz.cerow - 1, viz.cecol, { link })

  require("obsidian.ui").update(0)

  -- add the selected text to the end of the new note
  note:open { sync = true }
  vim.api.nvim_buf_set_lines(0, -1, -1, false, content)
end

local abc = require "obsidian.abc"
local completion = require "obsidian.completion.tags"
local iter = vim.iter
local obsidian = require "obsidian"

---Used to track variables that are used between reusable method calls. This is required, because each
---call to the sources's completion hook won't create a new source object, but will reuse the same one.
---@class obsidian.completion.sources.base.TagsSourceCompletionContext : obsidian.ABC
---@field client obsidian.Client
---@field completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback
---@field request obsidian.completion.sources.base.Request
---@field search string|?
---@field in_frontmatter boolean|?
local TagsSourceCompletionContext = abc.new_class()

TagsSourceCompletionContext.new = function()
  return TagsSourceCompletionContext.init()
end

---@class obsidian.completion.sources.base.TagsSourceBase : obsidian.ABC
---@field incomplete_response table
---@field complete_response table
local TagsSourceBase = abc.new_class()

---@return obsidian.completion.sources.base.TagsSourceBase
TagsSourceBase.new = function()
  return TagsSourceBase.init()
end

TagsSourceBase.get_trigger_characters = completion.get_trigger_characters

---Sets up a new completion context that is used to pass around variables between completion source methods
---@param completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback
---@param request obsidian.completion.sources.base.Request
---@return obsidian.completion.sources.base.TagsSourceCompletionContext
function TagsSourceBase:new_completion_context(completion_resolve_callback, request)
  local completion_context = TagsSourceCompletionContext.new()

  -- Sets up the completion callback, which will be called when the (possibly incomplete) completion items are ready
  completion_context.completion_resolve_callback = completion_resolve_callback

  -- This request object will be used to determine the current cursor location and the text around it
  completion_context.request = request

  completion_context.client = assert(obsidian.get_client())

  return completion_context
end

--- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods
---@param cc obsidian.completion.sources.base.TagsSourceCompletionContext
function TagsSourceBase:process_completion(cc)
  if not self:can_complete_request(cc) then
    return
  end

  local search_opts = cc.client.search_defaults()
  search_opts.sort = false

  cc.client:find_tags_async(cc.search, function(tag_locs)
    local tags = {}
    for tag_loc in iter(tag_locs) do
      tags[tag_loc.tag] = true
    end

    local items = {}
    for tag, _ in pairs(tags) do
      -- Generate context-appropriate text
      local insert_text, label_text
      if cc.in_frontmatter then
        -- Frontmatter: insert tag without # (YAML format)
        insert_text = tag
        label_text = "Tag: " .. tag
      else
        -- Document body: insert tag with # (Obsidian format)
        insert_text = "#" .. tag
        label_text = "Tag: #" .. tag
      end

      -- Calculate the range to replace (the entire #tag pattern)
      local cursor_before = cc.request.context.cursor_before_line
      local hash_start = string.find(cursor_before, "#[^%s]*$")
      local insert_start = hash_start and (hash_start - 1) or #cursor_before
      local insert_end = #cursor_before

      items[#items + 1] = {
        sortText = "#" .. tag,
        label = label_text,
        kind = vim.lsp.protocol.CompletionItemKind.Text,
        textEdit = {
          newText = insert_text,
          range = {
            ["start"] = {
              line = cc.request.context.cursor.row - 1,
              character = insert_start,
            },
            ["end"] = {
              line = cc.request.context.cursor.row - 1,
              character = insert_end,
            },
          },
        },
        data = {
          bufnr = cc.request.context.bufnr,
          in_frontmatter = cc.in_frontmatter,
          line = cc.request.context.cursor.line,
          tag = tag,
        },
      }
    end

    cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = items }))
  end, { search = search_opts })
end

--- Returns whatever it's possible to complete the search and sets up the search related variables in cc
---@param cc obsidian.completion.sources.base.TagsSourceCompletionContext
---@return boolean success provides a chance to return early if the request didn't meet the requirements
function TagsSourceBase:can_complete_request(cc)
  local can_complete
  can_complete, cc.search, cc.in_frontmatter = completion.can_complete(cc.request)

  if not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then
    cc.completion_resolve_callback(self.incomplete_response)
    return false
  end

  return true
end

return TagsSourceBase

local search = require "obsidian.search"
local api = require "obsidian.api"
local log = require "obsidian.log"

---@param data CommandArgs
return function(_, data)
  local viz = api.get_visual_selection()
  if not viz then
    log.err "ObsidianLink must be called with visual selection"
    return
  elseif #viz.lines ~= 1 then
    log.err "Only in-line visual selections allowed"
    return
  end

  local line = assert(viz.lines[1])

  ---@type string
  local search_term
  if data.args ~= nil and string.len(data.args) > 0 then
    search_term = data.args
  else
    search_term = viz.selection
  end

  ---@param note obsidian.Note
  local function insert_ref(note)
    local new_line = string.sub(line, 1, viz.cscol - 1)
      .. api.format_link(note, { label = viz.selection })
      .. string.sub(line, viz.cecol + 1)
    vim.api.nvim_buf_set_lines(0, viz.csrow - 1, viz.csrow, false, { new_line })
    require("obsidian.ui").update(0)
  end

  search.resolve_note_async(search_term, function(note)
    vim.schedule(function()
      insert_ref(note)
    end)
  end, { prompt_title = "Select note to link" })
end

local M = {}
local api = require "obsidian.api"

local ns_id = vim.api.nvim_create_namespace "ObsidianFooter"

--- Register buffer-specific variables
M.start = function(client)
  local refresh_info = function(buf)
    local note = api.current_note(buf)
    if not note then
      return
    end
    local info = {}
    local wc = vim.fn.wordcount()
    info.words = wc.words
    info.chars = wc.chars
    info.properties = vim.tbl_count(note:frontmatter())
    info.backlinks = #client:find_backlinks(note)
    return info
  end

  local function update_obsidian_footer(buf)
    local info = refresh_info(buf)
    if info == nil then
      return
    end
    local footer_text = assert(Obsidian.opts.footer.format)
    for k, v in pairs(info) do
      footer_text = footer_text:gsub("{{" .. k .. "}}", v)
    end
    local row0 = #vim.api.nvim_buf_get_lines(buf, 0, -2, false)
    local col0 = 0
    local separator = Obsidian.opts.footer.separator
    local hl_group = Obsidian.opts.footer.hl_group
    local footer_contents = { { footer_text, hl_group } }
    local footer_chunks
    if separator then
      local footer_separator = { { separator, hl_group } }
      footer_chunks = { footer_separator, footer_contents }
    else
      footer_chunks = { footer_contents }
    end
    local opts = { virt_lines = footer_chunks }
    vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1)
    vim.api.nvim_buf_set_extmark(buf, ns_id, row0, col0, opts)
  end

  local group = vim.api.nvim_create_augroup("obsidian_footer", {})
  local attached_bufs = {}
  vim.api.nvim_create_autocmd("User", {
    group = group,
    desc = "Initialize obsidian footer",
    pattern = "ObsidianNoteEnter",
    callback = function(ev)
      if attached_bufs[ev.buf] then
        return
      end
      vim.schedule(function()
        update_obsidian_footer(ev.buf)
      end)
      local id = vim.api.nvim_create_autocmd({
        "FileChangedShellPost",
        "TextChanged",
        "TextChangedI",
        "TextChangedP",
      }, {
        group = group,
        desc = "Update obsidian footer",
        buffer = ev.buf,
        callback = vim.schedule_wrap(function()
          update_obsidian_footer(ev.buf)
        end),
      })
      attached_bufs[ev.buf] = id
    end,
  })
end

return M

local M = {}
local uv = vim.uv
local api = require "obsidian.api"

--- Register the global variable that updates itself
M.start = function(client)
  local current_note

  local refresh = function()
    local note = api.current_note()
    if not note then -- no note
      return ""
    elseif current_note == note then -- no refresh
      return
    else -- refresh
      current_note = note
    end

    client:find_backlinks_async(
      note,
      vim.schedule_wrap(function(backlinks)
        local format = assert(Obsidian.opts.statusline.format)
        local wc = vim.fn.wordcount()
        local info = {
          words = wc.words,
          chars = wc.chars,
          backlinks = #backlinks,
          properties = vim.tbl_count(note:frontmatter()),
        }
        for k, v in pairs(info) do
          format = format:gsub("{{" .. k .. "}}", v)
        end
        vim.g.obsidian = format
      end)
    )
  end

  local timer = uv:new_timer()
  assert(timer, "Failed to create timer")
  timer:start(0, 1000, vim.schedule_wrap(refresh))
end

return M

local abc = require "obsidian.abc"
local util = require "obsidian.util"

---@class obsidian.yaml.Line : obsidian.ABC
---@field content string
---@field raw_content string
---@field indent integer
local Line = abc.new_class {
  __tostring = function(self)
    return string.format("Line('%s')", self.raw_content)
  end,
}

---Create a new Line instance from a raw line string.
---@param raw_line string
---@param base_indent integer|?
---@return obsidian.yaml.Line
Line.new = function(raw_line, base_indent)
  local self = Line.init()
  self.indent = util.count_indent(raw_line)
  if base_indent ~= nil then
    if base_indent > self.indent then
      error "relative indentation for line is less than base indentation"
    end
    self.indent = self.indent - base_indent
  end
  self.raw_content = util.lstrip_whitespace(raw_line, base_indent)
  self.content = vim.trim(self.raw_content)
  return self
end

---Check if a line is empty.
---@param self obsidian.yaml.Line
---@return boolean
Line.is_empty = function(self)
  if util.strip_comments(self.content) == "" then
    return true
  else
    return false
  end
end

return Line

local obsidian = require "obsidian"
local buf = vim.api.nvim_get_current_buf()
local buf_dir = vim.fs.dirname(vim.api.nvim_buf_get_name(buf))

local workspace = obsidian.Workspace.get_workspace_for_dir(buf_dir, Obsidian.opts.workspaces)
if not workspace then
  return -- if not in any workspace.
end

local win = vim.api.nvim_get_current_win()

vim.treesitter.start(buf, "markdown") -- for when user don't use nvim-treesitter
vim.wo[win].foldmethod = "expr"
vim.wo[win].foldexpr = "v:lua.vim.treesitter.foldexpr()"
vim.wo[win].foldlevel = 99

local log = require "obsidian.log"

---@param data CommandArgs
return function(_, data)
  local offset_days = 0
  local arg = string.gsub(data.args, " ", "")
  if string.len(arg) > 0 then
    local offset = tonumber(arg)
    if offset == nil then
      log.err "Invalid argument, expected an integer offset"
      return
    else
      offset_days = offset
    end
  end
  local note = require("obsidian.daily").daily(offset_days, {})
  note:open()
end

local api = require "obsidian.api"
local log = require "obsidian.log"
local Note = require "obsidian.note"

---@param data CommandArgs
return function(_, data)
  ---@type obsidian.Note
  local note
  if data.args:len() > 0 then
    note = Note.create { title = data.args }
  else
    local title = api.input("Enter title or path (optional): ", { completion = "file" })
    if not title then
      log.warn "Aborted"
      return
    elseif title == "" then
      title = nil
    end
    note = Note.create { title = title }
  end

  -- Open the note in a new buffer.
  note:open { sync = true }
  note:write_to_buffer()
end

local log = require "obsidian.log"
local api = require "obsidian.api"
local Note = require "obsidian.note"

---@param data CommandArgs
return function(_, data)
  local viz = api.get_visual_selection()
  if not viz then
    log.err "ObsidianLink must be called with visual selection"
    return
  elseif #viz.lines ~= 1 then
    log.err "Only in-line visual selections allowed"
    return
  end

  local line = assert(viz.lines[1])

  local title
  if string.len(data.args) > 0 then
    title = data.args
  else
    title = viz.selection
  end

  local note = Note.create { title = title }

  local new_line = string.sub(line, 1, viz.cscol - 1)
    .. api.format_link(note, { label = title })
    .. string.sub(line, viz.cecol + 1)

  vim.api.nvim_buf_set_lines(0, viz.csrow - 1, viz.csrow, false, { new_line })
end

local toggle_checkbox = require("obsidian.api").toggle_checkbox

---@param data CommandArgs
return function(_, data)
  local start_line, end_line
  local checkboxes = Obsidian.opts.checkbox.order
  start_line = data.line1
  end_line = data.line2

  local buf = vim.api.nvim_get_current_buf()

  for line_nb = start_line, end_line do
    local current_line = vim.api.nvim_buf_get_lines(buf, line_nb - 1, line_nb, false)[1]
    if current_line and current_line:match "%S" then
      toggle_checkbox(checkboxes, line_nb)
    end
  end
end

local NewNoteSourceBase = require "obsidian.completion.sources.base.new"
local abc = require "obsidian.abc"
local completion = require "obsidian.completion.refs"
local nvim_cmp_util = require "obsidian.completion.sources.nvim_cmp.util"

---@class obsidian.completion.sources.nvim_cmp.NewNoteSource : obsidian.completion.sources.base.NewNoteSourceBase
local NewNoteSource = abc.new_class()

NewNoteSource.new = function()
  return NewNoteSource.init(NewNoteSourceBase)
end

NewNoteSource.get_keyword_pattern = completion.get_keyword_pattern

NewNoteSource.incomplete_response = nvim_cmp_util.incomplete_response
NewNoteSource.complete_response = nvim_cmp_util.complete_response

---Invoke completion (required).
---@param request cmp.SourceCompletionApiParams
---@param callback fun(response: lsp.CompletionResponse|nil)
function NewNoteSource:complete(request, callback)
  local cc = self:new_completion_context(callback, request)
  self:process_completion(cc)
end

---Creates a new note using the default template for the completion item.
---Executed after the item was selected.
---@param completion_item lsp.CompletionItem
---@param callback fun(completion_item: lsp.CompletionItem|nil)
function NewNoteSource:execute(completion_item, callback)
  return callback(self:process_execute(completion_item))
end

return NewNoteSource

local M = {}

---Generates the completion request from a blink context
---@param context blink.cmp.Context
---@return obsidian.completion.sources.base.Request
M.generate_completion_request_from_editor_state = function(context)
  local row = context.cursor[1]
  local col = context.cursor[2] + 1
  local cursor_before_line = context.line:sub(1, col - 1)
  local cursor_after_line = context.line:sub(col)

  return {
    context = {
      bufnr = context.bufnr,
      cursor_before_line = cursor_before_line,
      cursor_after_line = cursor_after_line,
      cursor = {
        row = row,
        col = col,
        line = row + 1,
      },
    },
  }
end

M.incomplete_response = {
  is_incomplete_forward = true,
  is_incomplete_backward = true,
  items = {},
}

M.complete_response = {
  is_incomplete_forward = true,
  is_incomplete_backward = false,
  items = {},
}

return M

local api = require "obsidian.api"

---@param data CommandArgs
return function(_, data)
  local opts = {}
  if data.args and string.len(data.args) > 0 then
    opts.open_strategy = data.args
  end

  local link = api.cursor_link()

  if link then
    api.follow_link(link, opts)
  end
end

local compat = {}

local has_nvim_0_11 = false
if vim.fn.has "nvim-0.11" == 1 then
  has_nvim_0_11 = true
end

compat.is_list = function(t)
  if has_nvim_0_11 then
    return vim.islist(t)
  else
    return vim.tbl_islist(t)
  end
end

compat.flatten = function(t)
  if has_nvim_0_11 then
    ---@diagnostic disable-next-line: undefined-field
    return vim.iter(t):flatten():totable()
  else
    return vim.tbl_flatten(t)
  end
end

return compat

local NewNoteSourceBase = require "obsidian.completion.sources.base.new"
local abc = require "obsidian.abc"
local blink_util = require "obsidian.completion.sources.blink.util"

---@class obsidian.completion.sources.blink.NewNoteSource : obsidian.completion.sources.base.NewNoteSourceBase
local NewNoteSource = abc.new_class()

NewNoteSource.incomplete_response = blink_util.incomplete_response
NewNoteSource.complete_response = blink_util.complete_response

function NewNoteSource.new()
  return NewNoteSource.init(NewNoteSourceBase)
end

---Implement the get_completions method of the completion provider
---@param context blink.cmp.Context
---@param resolve fun(self: blink.cmp.CompletionResponse): nil
function NewNoteSource:get_completions(context, resolve)
  local request = blink_util.generate_completion_request_from_editor_state(context)
  local cc = self:new_completion_context(resolve, request)
  self:process_completion(cc)
end

---Implements the execute method of the completion provider
---@param _ blink.cmp.Context
---@param item blink.cmp.CompletionItem
---@param callback fun(),
---@param default_implementation fun(context?: blink.cmp.Context, item?: blink.cmp.CompletionItem)): ((fun(): nil) | nil)
function NewNoteSource:execute(_, item, callback, default_implementation)
  self:process_execute(item)
  default_implementation() -- Ensure completion is still executed
  callback() -- Required (as per blink documentation)
end

return NewNoteSource

local RefsSourceBase = require "obsidian.completion.sources.base.refs"
local abc = require "obsidian.abc"
local completion = require "obsidian.completion.refs"
local nvim_cmp_util = require "obsidian.completion.sources.nvim_cmp.util"

---@class obsidian.completion.sources.nvim_cmp.CompletionItem
---@field label string
---@field new_text string
---@field sort_text string
---@field documentation table|?

---@class obsidian.completion.sources.nvim_cmp.RefsSource : obsidian.completion.sources.base.RefsSourceBase
local RefsSource = abc.new_class()

RefsSource.new = function()
  return RefsSource.init(RefsSourceBase)
end

RefsSource.get_keyword_pattern = completion.get_keyword_pattern

RefsSource.incomplete_response = nvim_cmp_util.incomplete_response
RefsSource.complete_response = nvim_cmp_util.complete_response

function RefsSource:complete(request, callback)
  local cc = self:new_completion_context(callback, request)
  self:process_completion(cc)
end

return RefsSource

local RefsSourceBase = require "obsidian.completion.sources.base.refs"
local abc = require "obsidian.abc"
local blink_util = require "obsidian.completion.sources.blink.util"

---@class obsidian.completion.sources.blink.CompletionItem
---@field label string
---@field new_text string
---@field sort_text string
---@field documentation table|?

---@class obsidian.completion.sources.blink.RefsSource : obsidian.completion.sources.base.RefsSourceBase
local RefsSource = abc.new_class()

RefsSource.incomplete_response = blink_util.incomplete_response
RefsSource.complete_response = blink_util.complete_response

function RefsSource.new()
  return RefsSource.init(RefsSourceBase)
end

---Implement the get_completions method of the completion provider
---@param context blink.cmp.Context
---@param resolve fun(self: blink.cmp.CompletionResponse): nil
function RefsSource:get_completions(context, resolve)
  local request = blink_util.generate_completion_request_from_editor_state(context)
  local cc = self:new_completion_context(resolve, request)
  self:process_completion(cc)
end

return RefsSource

return function()
  local note = require("obsidian.daily").yesterday()
  note:open()
end

return function()
  local note = require("obsidian.daily").tomorrow()
  note:open()
end

local m = {}

---@param str string
---@return any
m.loads = function(str)
  local as_json = vim.fn.system("yq -o=json", str)
  local data = vim.json.decode(as_json, { luanil = { object = true, array = true } })
  return data
end

return m

local TagsSourceBase = require "obsidian.completion.sources.base.tags"
local abc = require "obsidian.abc"
local completion = require "obsidian.completion.tags"
local nvim_cmp_util = require "obsidian.completion.sources.nvim_cmp.util"

---@class obsidian.completion.sources.nvim_cmp.TagsSource : obsidian.completion.sources.base.TagsSourceBase
local TagsSource = abc.new_class()

TagsSource.new = function()
  return TagsSource.init(TagsSourceBase)
end

TagsSource.get_keyword_pattern = completion.get_keyword_pattern

TagsSource.incomplete_response = nvim_cmp_util.incomplete_response
TagsSource.complete_response = nvim_cmp_util.complete_response

function TagsSource:complete(request, callback)
  local cc = self:new_completion_context(callback, request)
  self:process_completion(cc)
end

return TagsSource

local TagsSourceBase = require "obsidian.completion.sources.base.tags"
local abc = require "obsidian.abc"
local blink_util = require "obsidian.completion.sources.blink.util"

---@class obsidian.completion.sources.blink.TagsSource : obsidian.completion.sources.base.TagsSourceBase
local TagsSource = abc.new_class()

TagsSource.incomplete_response = blink_util.incomplete_response
TagsSource.complete_response = blink_util.complete_response

function TagsSource.new()
  return TagsSource.init(TagsSourceBase)
end

---Implements the get_completions method of the completion provider
---@param context blink.cmp.Context
---@param resolve fun(self: blink.cmp.CompletionResponse): nil
function TagsSource:get_completions(context, resolve)
  local request = blink_util.generate_completion_request_from_editor_state(context)
  local cc = self:new_completion_context(resolve, request)
  self:process_completion(cc)
end

return TagsSource

---@class obsidian.completion.sources.base.Request.Context.Position
---@field public col integer
---@field public row integer

---A request context class that partially matches cmp.Context to serve as a common interface for completion sources
---@class obsidian.completion.sources.base.Request.Context
---@field public bufnr integer
---@field public cursor obsidian.completion.sources.base.Request.Context.Position|lsp.Position
---@field public cursor_after_line string
---@field public cursor_before_line string

---A request class that partially matches cmp.Request to serve as a common interface for completion sources
---@class obsidian.completion.sources.base.Request
---@field public context obsidian.completion.sources.base.Request.Context

local M = {}

M.incomplete_response = { isIncomplete = true }

M.complete_response = {
  isIncomplete = true,
  items = {},
}

return M

local M = {
  refs = require "obsidian.completion.refs",
  tags = require "obsidian.completion.tags",
}

return M

This code segment functions to: Dynamically initialize and return a component based on a specified name while providing fallback mechanisms through prioritized checks of available implementations, handling case normalization for configuration matching, and loading corresponding dependencies when required.

```lua
[TASK_BEGIN]
function(picker_name)
  local name = picker_name or PickerName
  local ok, picker = pcall(require, "obsidian.pickers." .. name:lower())
  if ok then
    return picker
  end
end
[TASK_END]
```

function(picker_name)
  local name = picker_name or PickerName
  local ok, picker = pcall(require, "obsidian.pickers." .. name:lower())
  if ok then
    return picker
  end
end