local api = vim.api

local queries = require "nvim-treesitter.query"
local ts_query = require "vim.treesitter.query"
local parsers = require "nvim-treesitter.parsers"
local utils = require "nvim-treesitter.utils"
local caching = require "nvim-treesitter.caching"

local M = {}

local config = {
  modules = {},
  sync_install = false,
  ensure_installed = {},
  ignore_install = {},
  update_strategy = "lockfile",
}
-- List of modules that need to be setup on initialization.
local queued_modules_defs = {}
-- Whether we've initialized the plugin yet.
local is_initialized = false
local builtin_modules = {
  highlight = {
    module_path = "nvim-treesitter.highlight",
    enable = false,
    custom_captures = {},
    is_supported = function(lang)
      return queries.has_highlights(lang)
    end,
    additional_vim_regex_highlighting = false,
  },
  incremental_selection = {
    module_path = "nvim-treesitter.incremental_selection",
    enable = false,
    keymaps = {
      init_selection = "gnn",
      node_incremental = "grn",
      scope_incremental = "grc",
      node_decremental = "grm",
    },
    is_supported = function()
      return true
    end,
  },
  indent = {
    module_path = "nvim-treesitter.indent",
    enable = false,
    is_supported = queries.has_indents,
  },
}

local attached_buffers_by_module = caching.create_buffer_cache()

-- Resolves a module by requiring the `module_path` or using the module definition.
local function resolve_module(mod_name)
  local config_mod = M.get_module(mod_name)

  if not config_mod then
    return
  end

  if type(config_mod.attach) == "function" and type(config_mod.detach) == "function" then
    return config_mod
  elseif type(config_mod.module_path) == "string" then
    return require(config_mod.module_path)
  end
end

-- Enables and attaches the module to a buffer for lang.
-- @param mod path to module
-- @param bufnr buffer number, defaults to current buffer
-- @param lang language, defaults to current language
local function enable_module(mod, bufnr, lang)
  bufnr = bufnr or api.nvim_get_current_buf()
  lang = lang or parsers.get_buf_lang(bufnr)
  M.attach_module(mod, bufnr, lang)
end

-- Enables autocomands for the module.
-- After the module is loaded `loaded` will be set to true for the module.
-- @param mod path to module
local function enable_mod_conf_autocmd(mod)
  local config_mod = M.get_module(mod)
  if not config_mod or config_mod.loaded then
    return
  end

  local cmd = string.format("lua require'nvim-treesitter.configs'.reattach_module('%s')", mod)
  api.nvim_command(string.format("autocmd NvimTreesitter FileType * %s", cmd))

  config_mod.loaded = true
end

-- Enables the module globally and for all current buffers.
-- After enabled, `enable` will be set to true for the module.
-- @param mod path to module
local function enable_all(mod)
  local config_mod = M.get_module(mod)
  if not config_mod then
    return
  end

  for _, bufnr in pairs(api.nvim_list_bufs()) do
    enable_module(mod, bufnr)
  end

  enable_mod_conf_autocmd(mod)
  config_mod.enable = true
end

-- Disables and detaches the module for a buffer.
-- @param mod path to module
-- @param bufnr buffer number, defaults to current buffer
local function disable_module(mod, bufnr)
  bufnr = bufnr or api.nvim_get_current_buf()
  M.detach_module(mod, bufnr)
end

-- Disables autocomands for the module.
-- After the module is unloaded `loaded` will be set to false for the module.
-- @param mod path to module
local function disable_mod_conf_autocmd(mod)
  local config_mod = M.get_module(mod)
  if not config_mod or not config_mod.loaded then
    return
  end
  -- TODO(kyazdani): detach the correct autocmd... doesn't work when using %s, cmd.
  -- This will remove all autocomands!
  api.nvim_command "autocmd! NvimTreesitter FileType *"
  config_mod.loaded = false
end

-- Disables the module globally and for all current buffers.
-- After disabled, `enable` will be set to false for the module.
-- @param mod path to module
local function disable_all(mod)
  local config_mod = M.get_module(mod)
  if not config_mod or not config_mod.enable then
    return
  end

  for _, bufnr in pairs(api.nvim_list_bufs()) do
    disable_module(mod, bufnr)
  end

  disable_mod_conf_autocmd(mod)
  config_mod.enable = false
end

-- Toggles a module for a buffer
-- @param mod path to module
-- @param bufnr buffer number, defaults to current buffer
-- @param lang language, defaults to current language
local function toggle_module(mod, bufnr, lang)
  bufnr = bufnr or api.nvim_get_current_buf()
  lang = lang or parsers.get_buf_lang(bufnr)

  if attached_buffers_by_module.has(mod, bufnr) then
    disable_module(mod, bufnr)
  else
    enable_module(mod, bufnr, lang)
  end
end

-- Toggles the module globally and for all current buffers.
-- @param mod path to module
local function toggle_all(mod)
  local config_mod = M.get_module(mod)
  if not config_mod then
    return
  end

  if config_mod.enable then
    disable_all(mod)
  else
    enable_all(mod)
  end
end

-- Recurses through all modules including submodules
-- @param accumulator function called for each module
-- @param root root configuration table to start at
-- @param path prefix path
local function recurse_modules(accumulator, root, path)
  root = root or config.modules

  for name, module in pairs(root) do
    local new_path = path and (path .. "." .. name) or name

    if M.is_module(module) then
      accumulator(name, module, new_path, root)
    elseif type(module) == "table" then
      recurse_modules(accumulator, module, new_path)
    end
  end
end

-- Shows current configuration of all nvim-treesitter modules
-- @param process_function function used as the `process` parameter
--        for vim.inspect (https://github.com/kikito/inspect.lua#optionsprocess)
local function config_info(process_function)
  process_function = process_function
    or function(item, path)
      if path[#path] == vim.inspect.METATABLE then
        return
      end
      if path[#path] == "is_supported" then
        return
      end
      return item
    end
  print(vim.inspect(config, { process = process_function }))
end

if not vim.ui then
  vim.ui = {
    select = function(items, opts, on_choice)
      vim.validate {
        items = { items, "table", false },
        on_choice = { on_choice, "function", false },
      }
      opts = opts or {}
      local choices = { opts.prompt or "Select one of:" }
      local format_item = opts.format_item or tostring
      for i, item in pairs(items) do
        table.insert(choices, string.format("%d: %s", i, format_item(item)))
      end
      local choice = vim.fn.inputlist(choices)
      if choice < 1 or choice > #items then
        on_choice(nil, nil)
      else
        on_choice(items[choice], choice)
      end
    end,
  }
end

function M.edit_query_file(query_group, lang)
  lang = lang or parsers.get_buf_lang()
  local files = ts_query.get_query_files(lang, query_group, true)
  if #files == 0 then
    utils.notify "No query file found! Creating a new one!"
    M.edit_query_file_user_after(query_group, lang)
  elseif #files == 1 then
    vim.cmd(":edit " .. files[1])
  else
    vim.ui.select(files, { prompt = "Select a file:" }, function(file)
      if file then
        vim.cmd(":edit " .. file)
      end
    end)
  end
end

function M.edit_query_file_user_after(query_group, lang)
  lang = lang or parsers.get_buf_lang()
  local folder = utils.join_path(vim.fn.stdpath "config", "after", "queries", lang)
  local file = utils.join_path(folder, query_group .. ".scm")
  if vim.fn.isdirectory(folder) ~= 1 then
    vim.ui.select({ "Yes", "No" }, { prompt = '"' .. folder .. '" does not exist. Create it?' }, function(choice)
      if choice == "Yes" then
        vim.fn.mkdir(folder, "p", "0755")
        vim.cmd(":edit " .. file)
      end
    end)
  else
    vim.cmd(":edit " .. file)
  end
end

M.commands = {
  TSBufEnable = {
    run = enable_module,
    args = {
      "-nargs=1",
      "-complete=custom,nvim_treesitter#available_modules",
    },
  },
  TSBufDisable = {
    run = disable_module,
    args = {
      "-nargs=1",
      "-complete=custom,nvim_treesitter#available_modules",
    },
  },
  TSBufToggle = {
    run = toggle_module,
    args = {
      "-nargs=1",
      "-complete=custom,nvim_treesitter#available_modules",
    },
  },
  TSEnableAll = {
    run = enable_all,
    args = {
      "-nargs=+",
      "-complete=custom,nvim_treesitter#available_modules",
    },
  },
  TSDisableAll = {
    run = disable_all,
    args = {
      "-nargs=+",
      "-complete=custom,nvim_treesitter#available_modules",
    },
  },
  TSToggleAll = {
    run = toggle_all,
    args = {
      "-nargs=+",
      "-complete=custom,nvim_treesitter#available_modules",
    },
  },
  TSConfigInfo = {
    run = config_info,
    args = {
      "-nargs=0",
    },
  },
  TSEditQuery = {
    run = M.edit_query_file,
    args = {
      "-nargs=+",
      "-complete=custom,nvim_treesitter#available_query_groups",
    },
  },
  TSEditQueryUserAfter = {
    run = M.edit_query_file_user_after,
    args = {
      "-nargs=+",
      "-complete=custom,nvim_treesitter#available_query_groups",
    },
  },
}

-- @param mod: module (string)
-- @param lang: the language of the buffer (string)
-- @param bufnr: the bufnr (number)
function M.is_enabled(mod, lang, bufnr)
  if not parsers.list[lang] or not parsers.has_parser(lang) then
    return false
  end

  local module_config = M.get_module(mod)
  if not module_config then
    return false
  end

  if not module_config.enable or not module_config.is_supported(lang) then
    return false
  end

  local disable = module_config.disable
  if type(disable) == "function" then
    if disable(lang, bufnr) then
      return false
    end
  elseif type(disable) == "table" then
    -- Otherwise it's a list of languages
    for _, parser in pairs(disable) do
      if lang == parser then
        return false
      end
    end
  end

  return true
end

-- Setup call for users to override module configurations.
-- @param user_data module overrides
function M.setup(user_data)
  config.modules = vim.tbl_deep_extend("force", config.modules, user_data)
  config.ignore_install = user_data.ignore_install or {}

  local ensure_installed = user_data.ensure_installed or {}
  if #ensure_installed > 0 then
    if user_data.sync_install then
      require("nvim-treesitter.install").ensure_installed_sync(ensure_installed)
    else
      require("nvim-treesitter.install").ensure_installed(ensure_installed)
    end
  end

  config.modules.ensure_installed = nil

  recurse_modules(function(_, _, new_path)
    local data = utils.get_at_path(config.modules, new_path)
    if data.enable then
      enable_all(new_path)
    end
  end, config.modules)
end

-- Defines a table of modules that can be attached/detached to buffers
-- based on language support. A module consist of the following properties:
-- * @enable Whether the modules is enabled. Can be true or false.
-- * @disable A list of languages to disable the module for. Only relevant if enable is true.
-- * @keymaps A list of user mappings for a given module if relevant.
-- * @is_supported A function which, given a ft, will return true if the ft works on the module.
-- * @module_path A string path to a module file using `require`. The exported module must contain
--                an `attach` and `detach` function. This path is not required if `attach` and `detach`
--                functions are provided directly on the module definition.
-- * @attach An attach function that is called for each buffer that the module is enabled for. This is required
--           if a `module_path` is not specified.
-- * @detach A detach function that is called for each buffer that the module is enabled for. This is required
--           if a `module_path` is not specified.
-- Modules are not setup until `init` is invoked by the plugin. This allows modules to be defined in any order
-- and can be loaded lazily.
-- @example
-- require"nvim-treesitter".define_modules {
--   my_cool_module = {
--     attach = function()
--       do_some_cool_setup()
--     end,
--     detach = function()
--       do_some_cool_teardown()
--     end
--   }
-- }
function M.define_modules(mod_defs)
  if not is_initialized then
    table.insert(queued_modules_defs, mod_defs)
    return
  end

  recurse_modules(function(key, mod, _, group)
    group[key] = vim.tbl_extend("keep", mod, {
      enable = false,
      disable = {},
      is_supported = function()
        return true
      end,
    })
  end, mod_defs)

  config.modules = vim.tbl_deep_extend("keep", config.modules, mod_defs)

  for _, mod in ipairs(M.available_modules(mod_defs)) do
    local module_config = M.get_module(mod)
    if module_config and module_config.enable then
      enable_mod_conf_autocmd(mod)
    end
  end
end

-- Attaches a module to a buffer
-- @param mod_name the module name
-- @param bufnr the bufnr
-- @param lang the language of the buffer
function M.attach_module(mod_name, bufnr, lang)
  bufnr = bufnr or api.nvim_get_current_buf()
  lang = lang or parsers.get_buf_lang(bufnr)
  local resolved_mod = resolve_module(mod_name)

  if resolved_mod and not attached_buffers_by_module.has(mod_name, bufnr) and M.is_enabled(mod_name, lang, bufnr) then
    attached_buffers_by_module.set(mod_name, bufnr, true)
    resolved_mod.attach(bufnr, lang)
  end
end

-- Detaches a module to a buffer
-- @param mod_name the module name
-- @param bufnr the bufnr
function M.detach_module(mod_name, bufnr)
  local resolved_mod = resolve_module(mod_name)
  bufnr = bufnr or api.nvim_get_current_buf()

  if resolved_mod and attached_buffers_by_module.has(mod_name, bufnr) then
    attached_buffers_by_module.remove(mod_name, bufnr)
    resolved_mod.detach(bufnr)
  end
end

-- Same as attach_module, but if the module is already attached, detach it first.
-- @param mod_name the module name
-- @param bufnr the bufnr
-- @param lang the language of the buffer
function M.reattach_module(mod_name, bufnr, lang)
  M.detach_module(mod_name, bufnr)
  M.attach_module(mod_name, bufnr, lang)
end

-- Gets available modules
-- @param root root table to find modules
function M.available_modules(root)
  local modules = {}

  recurse_modules(function(_, _, path)
    table.insert(modules, path)
  end, root)

  return modules
end

-- Gets a module config by path
-- @param mod_path path to the module
-- @returns the module or nil
function M.get_module(mod_path)
  local mod = utils.get_at_path(config.modules, mod_path)

  return M.is_module(mod) and mod or nil
end

-- Determines whether the provided table is a module.
-- A module should contain an attach and detach function.
-- @param mod the module table
function M.is_module(mod)
  return type(mod) == "table"
    and ((type(mod.attach) == "function" and type(mod.detach) == "function") or type(mod.module_path) == "string")
end

-- Initializes built-in modules and any queued modules
-- registered by plugins or the user.
function M.init()
  is_initialized = true
  M.define_modules(builtin_modules)

  for _, mod_def in ipairs(queued_modules_defs) do
    M.define_modules(mod_def)
  end
end

function M.get_update_strategy()
  return config.update_strategy
end

function M.get_ignored_parser_installs()
  return config.ignore_install or {}
end

return M
