local M = {}

function M.template(str, vars)
  return (str:gsub("{(.-)}", function(key)
    return tostring(vars[key] or "")
  end))
end

function M.uid()
  return tostring(os.time()) .. "-" .. tostring(math.random(1000, 9999))
end

function M.is_current_buf_a_file()
  local bufnr = vim.api.nvim_get_current_buf()
  local buftype = vim.api.nvim_buf_get_option(bufnr, "buftype")
  local filepath = vim.fn.expand('%:p')

  -- Valid files have empty buftype
  -- This excludes special buffers like help, terminal, nofile, etc.
  return buftype == "" and filepath ~= ""
end

function M.indent_code_block(text)
  if not text then return nil end
  local lines = vim.split(text, "\n", true)

  local first, last = nil, nil
  for i, line in ipairs(lines) do
    if line:match("[^%s]") then
      first = first or i
      last = i
    end
  end

  if not first then return "" end

  local content = {}
  for i = first, last do
    table.insert(content, lines[i])
  end

  local min_indent = math.huge
  for _, line in ipairs(content) do
    if line:match("[^%s]") then
      min_indent = math.min(min_indent, line:match("^%s*"):len())
    end
  end

  if min_indent < math.huge and min_indent > 0 then
    for i, line in ipairs(content) do
      if line:match("[^%s]") then
        content[i] = line:sub(min_indent + 1)
      end
    end
  end

  return vim.trim(table.concat(content, "\n"))
end

-- Get timezone offset in seconds for various timezone formats
function M.get_timezone_offset(timezone)
  -- Handle numeric timezone formats (+HHMM, -HHMM)
  if timezone:match("^[%+%-]%d%d:?%d%d$") then
    local sign = timezone:sub(1, 1) == "+" and 1 or -1
    local hours = tonumber(timezone:match("^[%+%-](%d%d)"))
    local mins = tonumber(timezone:match("^[%+%-]%d%d:?(%d%d)$") or "00")
    return sign * (hours * 3600 + mins * 60)
  end

  -- Map of common timezone abbreviations to their offset in seconds from UTC
  local timezone_map = {
    -- Zero offset timezones
    ["UTC"] = 0,
    ["GMT"] = 0,

    -- North America
    ["EST"] = -5 * 3600,
    ["EDT"] = -4 * 3600,
    ["CST"] = -6 * 3600,
    ["CDT"] = -5 * 3600,
    ["MST"] = -7 * 3600,
    ["MDT"] = -6 * 3600,
    ["PST"] = -8 * 3600,
    ["PDT"] = -7 * 3600,
    ["AKST"] = -9 * 3600,
    ["AKDT"] = -8 * 3600,
    ["HST"] = -10 * 3600,

    -- Europe
    ["WET"] = 0,
    ["WEST"] = 1 * 3600,
    ["CET"] = 1 * 3600,
    ["CEST"] = 2 * 3600,
    ["EET"] = 2 * 3600,
    ["EEST"] = 3 * 3600,
    ["MSK"] = 3 * 3600,
    ["BST"] = 1 * 3600,

    -- Asia & Middle East
    ["IST"] = 5.5 * 3600,
    ["PKT"] = 5 * 3600,
    ["HKT"] = 8 * 3600,
    ["PHT"] = 8 * 3600,
    ["JST"] = 9 * 3600,
    ["KST"] = 9 * 3600,

    -- Australia & Pacific
    ["AWST"] = 8 * 3600,
    ["ACST"] = 9.5 * 3600,
    ["AEST"] = 10 * 3600,
    ["AEDT"] = 11 * 3600,
    ["NZST"] = 12 * 3600,
    ["NZDT"] = 13 * 3600,
  }

  -- Handle special cases for ambiguous abbreviations
  if timezone == "CST" and not timezone_map[timezone] then
    -- In most contexts, CST refers to Central Standard Time (US)
    return -6 * 3600
  end

  -- Return the timezone offset or default to UTC (0)
  return timezone_map[timezone] or 0
end

-- Reset all ANSI styling
function M.ansi_reset()
  return "\27[0m"
end

--- Convert a datetime string to a human-readable "time ago" format
-- @param dateTime string: Datetime string (e.g., "2025-03-02 12:39:02 UTC")
-- @return string: Human-readable time ago string (e.g., "2 hours ago")

-- Simple YAML key/value setter
-- Note: This is a basic implementation that assumes simple YAML structure
-- It will either update an existing key or append a new key at the end
function M.set_yaml_value(path, key, value)
  if not path then
    return false, "No file path provided"
  end

  -- Read the current content
  local lines = {}
  local key_pattern = "^%s*" .. vim.pesc(key) .. ":%s*"
  local found = false

  local file = io.open(path, "r")
  if not file then
    return false, "Could not open file"
  end

  for line in file:lines() do
    if line:match(key_pattern) then
      -- Update existing key
      lines[#lines + 1] = string.format("%s: %s", key, value)
      found = true
    else
      lines[#lines + 1] = line
    end
  end
  file:close()

  -- If key wasn't found, append it
  if not found then
    lines[#lines + 1] = string.format("%s: %s", key, value)
  end

  -- Write back to file
  file = io.open(path, "w")
  if not file then
    return false, "Could not open file for writing"
  end
  file:write(table.concat(lines, "\n"))
  file:close()

  return true
end

return M

function M.time_ago(dateTime)
  local year, month, day, hour, min, sec, zone = dateTime:match(
    "(%d+)%-(%d+)%-(%d+)%s+(%d+):(%d+):(%d+)%s+([%w%+%-/:]+)")
  if not year then
    year, month, day, hour, min, sec = dateTime:match("(%d+)%-(%d+)%-(%d+)[T ](%d+):(%d+):(%d+)")
  end
  if not year then
    return "Invalid date format"
  end
  year, month, day = tonumber(year), tonumber(month), tonumber(day)
  hour, min, sec = tonumber(hour), tonumber(min), tonumber(sec)
  local now = os.time()
  local date_table = {
    year = year,
    month = month,
    day = day,
    hour = hour,
    min = min,
    sec = sec,
    isdst = false
  }
  local timestamp
  if zone then
    local input_offset_seconds = M.get_timezone_offset(zone)
    local local_offset_seconds = os.difftime(os.time(os.date("*t", now)), os.time(os.date("!*t", now)))
    local adjusted_hour = hour - (input_offset_seconds / 3600) + (local_offset_seconds / 3600)
    date_table.hour = math.floor(adjusted_hour)
    date_table.min = math.floor(min + ((adjusted_hour % 1) * 60))
    timestamp = os.time(date_table)
  else
    timestamp = os.time(date_table)
  end
  local diff = now - timestamp
  if diff < 0 then
    return "in the future"
  elseif diff < 60 then
    return "just now"
  elseif diff < 3600 then
    local mins = math.floor(diff / 60)
    return mins == 1 and "1 minute ago" or mins .. " minutes ago"
  elseif diff < 86400 then
    local hours = math.floor(diff / 3600)
    return hours == 1 and "1 hour ago" or hours .. " hours ago"
  elseif diff < 604800 then
    local days = math.floor(diff / 86400)
    return days == 1 and "1 day ago" or days .. " days ago"
  elseif diff < 2592000 then
    local weeks = math.floor(diff / 604800)
    return weeks == 1 and "1 week ago" or weeks .. " weeks ago"
  elseif diff < 31536000 then
    local months = math.floor(diff / 2592000)
    return months == 1 and "1 month ago" or months .. " months ago"
  else
    local years = math.floor(diff / 31536000)
    return years == 1 and "1 year ago" or years .. " years ago"
  end
end

local Path = require('plenary.path')
local M = {}

M.__changed_files = nil
M.__current_file_index = nil
M.__diff_tab = nil

local git = {
  is_project = function()
    if M.__is_git_project ~= nil then
      return M.__is_git_project
    end

    local git_dir = Path:new(vim.fn.getcwd()):joinpath('.git')
    M.__is_git_project = git_dir:exists() and git_dir:is_dir()
    return M.__is_git_project
  end,

  list_changed_files = function()
    local result = vim.fn.system('git ls-files -m -o --exclude-standard')
    return result
  end,

  is_tracked = function(file_path)
    local success = os.execute('git ls-files --error-unmatch "' .. file_path .. '" > /dev/null 2>&1')
    return success == 0
  end,

  get_head_content = function(file_path, output_path)
    local success = os.execute('git show HEAD:"' .. file_path .. '" > "' .. output_path .. '" 2>/dev/null')
    return success == 0
  end
}

local function require_git_project(fn, silent)
  return function(...)
    if not git.is_project() then
      if not silent then
        vim.notify("Error: Not in a git project.")
      end
      return
    end
    return fn(...)
  end
end

-- File helpers
local function get_snapshot_dir()
  local cwd = vim.fn.getcwd()
  local cwd_hash = vim.fn.sha256(cwd)
  return Path:new(vim.fn.stdpath('data')):joinpath('goose', 'snapshot', cwd_hash)
end

local function revert_file(file_path, snapshot_path)
  if snapshot_path then
    Path:new(snapshot_path):copy({ destination = file_path, override = true })
  elseif git.is_tracked(file_path) then
    local temp_file = Path:new(vim.fn.tempname())
    if git.get_head_content(file_path, tostring(temp_file)) then
      temp_file:copy({ destination = file_path, override = true })
      temp_file:rm()
    end
  else
    local absolute_path = vim.fn.fnamemodify(file_path, ":p")
    local bufnr = vim.fn.bufnr(absolute_path)
    if bufnr ~= -1 then
      vim.api.nvim_command('silent! bdelete! ' .. bufnr)
    end
    Path:new(file_path):rm()
  end

  vim.cmd('checktime')
  return true
end

local function close_diff_tab()
  if M.__diff_tab and vim.api.nvim_tabpage_is_valid(M.__diff_tab) then
    pcall(vim.api.nvim_del_augroup_by_name, "GooseDiffCleanup" .. M.__diff_tab)

    local windows = vim.api.nvim_tabpage_list_wins(M.__diff_tab)

    local buffers = {}
    for _, win in ipairs(windows) do
      local buf = vim.api.nvim_win_get_buf(win)
      table.insert(buffers, buf)
    end

    vim.api.nvim_set_current_tabpage(M.__diff_tab)
    pcall(vim.cmd, 'tabclose')

    for _, buf in ipairs(buffers) do
      if vim.api.nvim_buf_is_valid(buf) then
        local visible = false
        for _, win in ipairs(vim.api.nvim_list_wins()) do
          if vim.api.nvim_win_get_buf(win) == buf then
            visible = true
            break
          end
        end

        if not visible then
          pcall(vim.api.nvim_buf_delete, buf, { force = true })
        end
      end
    end
  end
  M.__diff_tab = nil
end

local function files_are_different(file1, file2)
  local result = vim.fn.system('cmp -s "' .. file1 .. '" "' .. file2 .. '"; echo $?')
  return tonumber(result) ~= 0
end

local function get_changed_files()
  local files = {}
  local snapshot_base = get_snapshot_dir()

  if not snapshot_base:exists() then return files end

  local git_files = git.list_changed_files()
  for file in git_files:gmatch("[^\n]+") do
    local snapshot_file = snapshot_base:joinpath(file)

    if snapshot_file:exists() then
      if files_are_different(file, tostring(snapshot_file)) then
        table.insert(files, { file, tostring(snapshot_file) })
      end
    else
      table.insert(files, { file, nil })
    end
  end

  M.__changed_files = files
  return files
end

local function show_file_diff(file_path, snapshot_path)
  close_diff_tab()

  vim.cmd('tabnew')
  M.__diff_tab = vim.api.nvim_get_current_tabpage()

  if snapshot_path then
    -- Compare with snapshot file
    vim.cmd('edit ' .. snapshot_path)
    vim.cmd('setlocal readonly buftype=nofile nomodifiable')
    vim.cmd('diffthis')

    vim.cmd('vsplit ' .. file_path)
    vim.cmd('diffthis')
  else
    -- If file is tracked by git, compare with HEAD, otherwise just open it
    if git.is_tracked(file_path) then
      -- Create a temporary file from the HEAD version
      local temp_file = vim.fn.tempname()
      if git.get_head_content(file_path, temp_file) then
        -- First edit the current file
        vim.cmd('edit ' .. file_path)
        local file_type = vim.bo.filetype

        -- Then split with HEAD version on the left
        vim.cmd('leftabove vsplit ' .. temp_file)
        vim.cmd('setlocal readonly buftype=nofile nomodifiable filetype=' .. file_type)
        vim.cmd('diffthis')

        -- Go back to current file window and enable diff there
        vim.cmd('wincmd l')
        vim.cmd('diffthis')
      else
        -- File is not tracked by git, just open it
        vim.cmd('edit ' .. file_path)
      end
    else
      -- File is not tracked by git, just open it
      vim.cmd('edit ' .. file_path)
    end
  end

  -- auto close tab if any diff window is closed
  local augroup = vim.api.nvim_create_augroup("GooseDiffCleanup" .. M.__diff_tab, { clear = true })
  local tab_windows = vim.api.nvim_tabpage_list_wins(M.__diff_tab)
  vim.api.nvim_create_autocmd("WinClosed", {
    group = augroup,
    pattern = tostring(tab_windows[1]) .. ',' .. tostring(tab_windows[2]),
    callback = function() close_diff_tab() end
  })
end

local function display_file_at_index(idx)
  local file_data = M.__changed_files[idx]
  local file_name = vim.fn.fnamemodify(file_data[1], ":t")
  vim.notify(string.format("Showing file %d of %d: %s", idx, #M.__changed_files, file_name))
  show_file_diff(file_data[1], file_data[2])
end

-- Public functions

M.review = require_git_project(function()
  local files = get_changed_files()

  if #files == 0 then
    vim.notify("No changes to review.")
    return
  end

  if #files == 1 then
    M.__current_file_index = 1
    show_file_diff(files[1][1], files[1][2])
  else
    vim.ui.select(vim.tbl_map(function(f) return f[1] end, files),
      { prompt = "Select a file to review:" },
      function(choice, idx)
        if not choice then return end
        M.__current_file_index = idx
        show_file_diff(files[idx][1], files[idx][2])
      end)
  end
end)

M.next_diff = require_git_project(function()
  if not M.__changed_files or not M.__current_file_index or M.__current_file_index >= #M.__changed_files then
    local files = get_changed_files()
    if #files == 0 then
      vim.notify("No changes to review.")
      return
    end
    M.__current_file_index = 1
  else
    M.__current_file_index = M.__current_file_index + 1
  end

  display_file_at_index(M.__current_file_index)
end)

M.prev_diff = require_git_project(function()
  if not M.__changed_files or #M.__changed_files == 0 then
    local files = get_changed_files()
    if #files == 0 then
      vim.notify("No changes to review.")
      return
    end
    M.__current_file_index = #files
  else
    if not M.__current_file_index or M.__current_file_index <= 1 then
      M.__current_file_index = #M.__changed_files
    else
      M.__current_file_index = M.__current_file_index - 1
    end
  end

  display_file_at_index(M.__current_file_index)
end)

M.close_diff = function()
  close_diff_tab()
end

M.check_cleanup_breakpoint = function()
  local changed_files = get_changed_files()
  if #changed_files == 0 then
    local snapshot_base = get_snapshot_dir()
    if snapshot_base:exists() then
      snapshot_base:rm({ recursive = true })
    end
  end
end

M.set_breakpoint = require_git_project(function()
  local snapshot_base = get_snapshot_dir()

  if snapshot_base:exists() then
    snapshot_base:rm({ recursive = true })
  end

  snapshot_base:mkdir({ parents = true })

  for file in git.list_changed_files():gmatch("[^\n]+") do
    local source_file = Path:new(file)
    local target_file = snapshot_base:joinpath(file)
    target_file:parent():mkdir({ parents = true })
    source_file:copy({ destination = target_file })
  end
end, true)

M.revert_all = require_git_project(function()
  local files = get_changed_files()

  if #files == 0 then
    vim.notify("No changes to revert.")
    return
  end

  if vim.fn.input("Revert all " .. #files .. " changed files? (y/n): "):lower() ~= "y" then
    return
  end

  local success_count = 0
  for _, file_data in ipairs(files) do
    if revert_file(file_data[1], file_data[2]) then
      success_count = success_count + 1
    end
  end

  vim.notify("Reverted " .. success_count .. " of " .. #files .. " files.")
end)

M.revert_current = require_git_project(function()
  local files = get_changed_files()
  local current_file = vim.fn.expand('%:p')
  local rel_path = vim.fn.fnamemodify(current_file, ':.')

  local changed_file = nil
  for _, file_data in ipairs(files) do
    if file_data[1] == rel_path then
      changed_file = file_data
      break
    end
  end

  if not changed_file then
    vim.notify("No changes to revert.")
    return
  end

  if vim.fn.input("Revert current file? (y/n): "):lower() ~= "y" then
    return
  end

  if revert_file(changed_file[1], changed_file[2]) then
    vim.cmd('e!')
  end
end)

M.reset_git_status = function()
  M.__is_git_project = nil
end

return M

local M = {}

local INPUT_PLACEHOLDER = 'Plan, search, build anything'
local config = require("goose.config").get()
local state = require("goose.state")
local ui_util = require('goose.ui.util')

M.base_window_opts = {
  relative = 'editor',
  style = 'minimal',
  border = 'rounded',
  zindex = 50,
  width = 1,
  height = 1,
  col = 0,
  row = 0
}

function M.setup_options(windows)
  -- Input window/buffer options
  vim.api.nvim_win_set_option(windows.input_win, 'winhighlight', 'Normal:GooseBackground,FloatBorder:GooseBorder')
  vim.api.nvim_win_set_option(windows.input_win, 'signcolumn', 'yes')
  vim.api.nvim_win_set_option(windows.input_win, 'cursorline', false)
  vim.api.nvim_buf_set_option(windows.input_buf, 'buftype', 'nofile')
  vim.api.nvim_buf_set_option(windows.input_buf, 'swapfile', false)
  vim.b[windows.input_buf].completion = false

  -- Output window/buffer options
  vim.api.nvim_win_set_option(windows.output_win, 'winhighlight', 'Normal:GooseBackground,FloatBorder:GooseBorder')
  vim.api.nvim_buf_set_option(windows.output_buf, 'filetype', 'markdown')
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', false)
  vim.api.nvim_buf_set_option(windows.output_buf, 'buftype', 'nofile')
  vim.api.nvim_buf_set_option(windows.output_buf, 'swapfile', false)
end

function M.refresh_placeholder(windows, input_lines)
  -- show placeholder if input buffer is empty - otherwise clear it
  if not input_lines then
    input_lines = vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false)
  end

  if #input_lines == 1 and input_lines[1] == "" then
    local ns_id = vim.api.nvim_create_namespace('input-placeholder')
    vim.api.nvim_buf_set_extmark(windows.input_buf, ns_id, 0, 0, {
      virt_text = { { INPUT_PLACEHOLDER, 'Comment' } },
      virt_text_pos = 'overlay',
    })
  else
    vim.api.nvim_buf_clear_namespace(windows.input_buf, vim.api.nvim_create_namespace('input-placeholder'), 0, -1)
  end
end

function M.setup_autocmds(windows)
  local group = vim.api.nvim_create_augroup('GooseWindows', { clear = true })

  -- Output window autocmds
  vim.api.nvim_create_autocmd({ 'WinEnter', 'BufEnter' }, {
    group = group,
    buffer = windows.output_buf,
    callback = function()
      vim.cmd('stopinsert')
      state.last_focused_goose_window = "output"
      M.refresh_placeholder(windows)
    end
  })

  -- Input window autocmds
  vim.api.nvim_create_autocmd('WinEnter', {
    group = group,
    buffer = windows.input_buf,
    callback = function()
      M.refresh_placeholder(windows)
      state.last_focused_goose_window = "input"
    end
  })

  vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
    buffer = windows.input_buf,
    callback = function()
      local input_lines = vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false)
      state.input_content = input_lines
      M.refresh_placeholder(windows, input_lines)
    end
  })

  vim.api.nvim_create_autocmd('WinClosed', {
    group = group,
    pattern = tostring(windows.input_win) .. ',' .. tostring(windows.output_win),
    callback = function(opts)
      -- Get the window that was closed
      local closed_win = tonumber(opts.match)
      -- If either window is closed, close both
      if closed_win == windows.input_win or closed_win == windows.output_win then
        vim.schedule(function()
          require('goose.ui.ui').close_windows(windows)
        end)
      end
    end
  })

  vim.api.nvim_create_autocmd('WinLeave', {
    group = group,
    pattern = "*",
    callback = function()
      if not require('goose.ui.ui').is_goose_focused() then
        require('goose.context').load()
        state.last_code_win_before_goose = vim.api.nvim_get_current_win()
      end
    end
  })

  vim.api.nvim_create_autocmd('WinLeave', {
    group = group,
    buffer = windows.input_buf,
    callback = function()
      state.last_input_window_position = vim.api.nvim_win_get_cursor(0)
    end
  })

  vim.api.nvim_create_autocmd('WinLeave', {
    group = group,
    buffer = windows.output_buf,
    callback = function()
      state.last_output_window_position = vim.api.nvim_win_get_cursor(0)
    end
  })
end

function M.configure_window_dimensions(windows)
  local total_width = vim.api.nvim_get_option('columns')
  local total_height = vim.api.nvim_get_option('lines')
  local is_fullscreen = config.ui.fullscreen

  local width
  if is_fullscreen then
    width = total_width
  else
    width = math.floor(total_width * config.ui.window_width)
  end

  local layout = config.ui.layout
  local total_usable_height
  local row, col

  if layout == "center" then
    -- Use a smaller height for floating; allow an optional `floating_height` factor (e.g. 0.8).
    local fh = config.ui.floating_height
    total_usable_height = math.floor(total_height * fh)
    -- Center the floating window vertically and horizontally.
    row = math.floor((total_height - total_usable_height) / 2)
    col = is_fullscreen and 0 or math.floor((total_width - width) / 2)
  else
    -- "right" layout uses the original full usable height.
    total_usable_height = total_height - 3
    row = 0
    col = is_fullscreen and 0 or (total_width - width)
  end

  local input_height = math.floor(total_usable_height * config.ui.input_height)
  local output_height = total_usable_height - input_height - 2

  vim.api.nvim_win_set_config(windows.output_win, {
    relative = 'editor',
    width = width,
    height = output_height,
    col = col,
    row = row,
  })

  vim.api.nvim_win_set_config(windows.input_win, {
    relative = 'editor',
    width = width,
    height = input_height,
    col = col,
    row = row + output_height + 2,
  })
end

function M.setup_resize_handler(windows)
  local function cb()
    M.configure_window_dimensions(windows)
    require('goose.ui.topbar').render()
  end

  vim.api.nvim_create_autocmd('VimResized', {
    group = vim.api.nvim_create_augroup('GooseResize', { clear = true }),
    callback = cb
  })
end

local function recover_input(windows)
  local input_content = state.input_content
  require('goose.ui.ui').write_to_input(input_content, windows)
  require('goose.ui.mention').highlight_all_mentions(windows.input_buf)
end

function M.setup_after_actions(windows)
  recover_input(windows)
end

local function handle_submit(windows)
  local input_content = table.concat(vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false), '\n')
  vim.api.nvim_buf_set_lines(windows.input_buf, 0, -1, false, {})
  vim.api.nvim_exec_autocmds('TextChanged', {
    buffer = windows.input_buf,
    modeline = false
  })

  -- Switch to the output window
  vim.api.nvim_set_current_win(windows.output_win)

  -- Always scroll to the bottom when submitting a new prompt
  local line_count = vim.api.nvim_buf_line_count(windows.output_buf)
  vim.api.nvim_win_set_cursor(windows.output_win, { line_count, 0 })

  -- Run the command with the input content
  require("goose.core").run(input_content)
end

function M.setup_keymaps(windows)
  local window_keymap = config.keymap.window
  local api = require('goose.api')

  vim.keymap.set({  'n' }, window_keymap.submit, function()
    handle_submit(windows)
  end, { buffer = windows.input_buf, silent = false })

  vim.keymap.set({  'i' }, window_keymap.submit_insert, function()
    handle_submit(windows)
  end, { buffer = windows.input_buf, silent = false })

  vim.keymap.set('n', window_keymap.close, function()
    api.close()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set('n', window_keymap.close, function()
    api.close()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set('n', window_keymap.next_message, function()
    require('goose.ui.navigation').goto_next_message()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set('n', window_keymap.prev_message, function()
    require('goose.ui.navigation').goto_prev_message()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set('n', window_keymap.next_message, function()
    require('goose.ui.navigation').goto_next_message()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set('n', window_keymap.prev_message, function()
    require('goose.ui.navigation').goto_prev_message()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.stop, function()
    api.stop()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.stop, function()
    api.stop()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set('i', window_keymap.mention_file, function()
    require('goose.core').add_file_to_context()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.toggle_pane, function()
    api.toggle_pane()
  end, { buffer = windows.input_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.toggle_pane, function()
    api.toggle_pane()
  end, { buffer = windows.output_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.prev_prompt_history,
    ui_util.navigate_history('prev', window_keymap.prev_prompt_history, api.prev_history, api.next_history),
    { buffer = windows.input_buf, silent = true })

  vim.keymap.set({ 'n', 'i' }, window_keymap.next_prompt_history,
    ui_util.navigate_history('next', window_keymap.next_prompt_history, api.prev_history, api.next_history),
    { buffer = windows.input_buf, silent = true })
end

return M

-- Gathers editor context

local template = require("goose.template")
local util = require("goose.util")
local config = require("goose.config");

local M = {}

M.context = {
  -- current file
  current_file = nil,
  cursor_data = nil,

  -- attachments
  mentioned_files = nil,
  selections = nil,
  linter_errors = nil
}

function M.unload_attachments()
  M.context.mentioned_files = nil
  M.context.selections = nil
  M.context.linter_errors = nil
end

function M.load()
  if util.is_current_buf_a_file() then
    local current_file = M.get_current_file()
    local cursor_data = M.get_current_cursor_data()

    M.context.current_file = current_file
    M.context.cursor_data = cursor_data
    M.context.linter_errors = M.check_linter_errors()
  end

  local current_selection = M.get_current_selection()
  if current_selection then
    local selection = M.new_selection(
      M.context.current_file,
      current_selection.text,
      current_selection.lines
    )
    M.add_selection(selection)
  end
end

function M.check_linter_errors()
  local diagnostics = vim.diagnostic.get(0, { severity = vim.diagnostic.severity.ERROR })
  if #diagnostics == 0 then
    return nil
  end

  local message = "Found " .. #diagnostics .. " error" .. (#diagnostics > 1 and "s" or "") .. ":"

  for i, diagnostic in ipairs(diagnostics) do
    local line_number = diagnostic.lnum + 1 -- Convert to 1-based line numbers
    local short_message = diagnostic.message:gsub("%s+", " "):gsub("^%s", ""):gsub("%s$", "")
    message = message .. "\n Line " .. line_number .. ": " .. short_message
  end

  return message
end

function M.new_selection(file, content, lines)
  return {
    file = file,
    content = util.indent_code_block(content),
    lines = lines
  }
end

function M.add_selection(selection)
  if not M.context.selections then
    M.context.selections = {}
  end

  table.insert(M.context.selections, selection)
end

function M.add_file(file)
  if not M.context.mentioned_files then
    M.context.mentioned_files = {}
  end

  if vim.fn.filereadable(file) ~= 1 then
    vim.notify("File not added to context. Could not read.")
    return
  end

  if not vim.tbl_contains(M.context.mentioned_files, file) then
    table.insert(M.context.mentioned_files, file)
  end
end

function M.delta_context()
  local context = vim.deepcopy(M.context)
  local last_context = require('goose.state').last_sent_context
  if not last_context then return context end

  -- no need to send file context again
  if context.current_file and context.current_file.name == last_context and last_context.current_file.name then
    context.current_file = nil
  end

  return context
end

function M.get_current_file()
  local file = vim.fn.expand('%:p')
  if not file or file == "" or vim.fn.filereadable(file) ~= 1 then
    return nil
  end
  return {
    path = file,
    name = vim.fn.fnamemodify(file, ":t"),
    extension = vim.fn.fnamemodify(file, ":e")
  }
end

function M.get_current_cursor_data()
  if not (config.get("context") and config.get("context").cursor_data) then
    return nil
  end

  local cursor_pos = vim.fn.getcurpos()
  local cursor_content = vim.trim(vim.api.nvim_get_current_line())
  return { line = cursor_pos[2], col = cursor_pos[3], line_content = cursor_content }
end

function M.get_current_selection()
  -- Return nil if not in a visual mode
  if not vim.fn.mode():match("[vV\022]") then
    return nil
  end

  -- Save current position and register state
  local current_pos = vim.fn.getpos(".")
  local old_reg = vim.fn.getreg('x')
  local old_regtype = vim.fn.getregtype('x')

  -- Capture selection text and position
  vim.cmd('normal! "xy')
  local text = vim.fn.getreg('x')

  -- Get line numbers
  vim.cmd("normal! `<")
  local start_line = vim.fn.line(".")
  vim.cmd("normal! `>")
  local end_line = vim.fn.line(".")

  -- Restore state
  vim.fn.setreg('x', old_reg, old_regtype)
  vim.cmd('normal! gv')
  vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', true)
  vim.fn.setpos('.', current_pos)

  return {
    text = text and text:match("[^%s]") and text or nil,
    lines = start_line .. ", " .. end_line
  }
end

function M.format_message(prompt)
  local info = require('goose.info')
  local context = nil

  if info.parse_goose_info().goose_mode == info.GOOSE_MODE.CHAT then
    -- For chat mode only send selection context
    context = {
      selections = M.context.selections
    }
  else
    context = M.delta_context()
  end

  context.prompt = prompt
  return template.render_template(context)
end

function M.extract_from_message(text)
  local context = {
    prompt = template.extract_tag('user-query', text) or text,
    selected_text = template.extract_tag('manually-added-selection', text)
  }
  return context
end

return M

-- Default and user-provided settings for goose.nvim

local M = {}

-- Default configuration
M.defaults = {
  prefered_picker = nil,
  default_global_keymaps = true,
  keymap = {
    global = {
      toggle = '<leader>gg',
      open_input = '<leader>gi',
      open_input_new_session = '<leader>gI',
      open_output = '<leader>go',
      toggle_focus = '<leader>gt',
      close = '<leader>gq',
      toggle_fullscreen = '<leader>gf',
      select_session = '<leader>gs',
      goose_mode_chat = '<leader>gmc',
      goose_mode_auto = '<leader>gma',
      configure_provider = '<leader>gp',
      diff_open = '<leader>gd',
      diff_next = '<leader>g]',
      diff_prev = '<leader>g[',
      diff_close = '<leader>gc',
      diff_revert_all = '<leader>gra',
      diff_revert_this = '<leader>grt'
    },
    window = {
      submit = '<cr>',
      submit_insert = '<cr>',
      close = '<esc>',
      stop = '<C-c>',
      next_message = ']]',
      prev_message = '[[',
      mention_file = '@',
      toggle_pane = '<tab>',
      prev_prompt_history = '<up>',
      next_prompt_history = '<down>'
    }
  },
  ui = {
    window_width = 0.35,
    input_height = 0.15,
    fullscreen = false,
    layout = "right",
    floating_height = 0.8,
    display_model = true,
    display_goose_mode = true
  },
  providers = {
    --[[
    Define available providers and their models for quick model switching
    anthropic|azure|bedrock|databricks|google|groq|ollama|openai|openrouter
    Example:
    openrouter = {
      "anthropic/claude-3.5-sonnet",
      "openai/gpt-4.1",
    },
    ollama = {
      "cogito:14b"
    }
    --]]
  },
  context = {
    cursor_data = false, -- Send cursor position and current line content as context data
  },
}

-- Active configuration
M.values = vim.deepcopy(M.defaults)

function M.setup(opts)
  opts = opts or {}

  if opts.default_global_keymaps == false then
    M.values.keymap.global = {}
  end

  -- Merge user options with defaults (deep merge for nested tables)
  for k, v in pairs(opts) do
    if type(v) == "table" and type(M.values[k]) == "table" then
      M.values[k] = vim.tbl_deep_extend("force", M.values[k], v)
    else
      M.values[k] = v
    end
  end
end

function M.get(key)
  if key then
    return M.values[key]
  end
  return M.values
end

return M

local M = {}

local state = require("goose.state")
local formatter = require("goose.ui.session_formatter")

local LABELS = {
  GENERATING_RESPONSE = "Thinking..."
}

M._cache = {
  last_modified = 0,
  output_lines = nil,
  session_path = nil,
  check_counter = 0
}

M._animation = {
  frames = { "·", "․", "•", "∙", "●", "⬤", "●", "∙", "•", "․" },
  current_frame = 1,
  timer = nil,
  loading_line = nil,
  fps = 10,
}

function M.render_markdown()
  if vim.fn.exists(":RenderMarkdown") > 0 then
    vim.cmd(':RenderMarkdown')
  end
end

function M._should_refresh_content()
  if not state.active_session then return true end

  local session_path = state.active_session.path

  if session_path ~= M._cache.session_path then
    M._cache.session_path = session_path
    return true
  end

  if vim.fn.filereadable(session_path) == 0 then return false end

  local stat = vim.loop.fs_stat(session_path)
  if not stat then return false end

  if state.goose_run_job then
    M._cache.check_counter = (M._cache.check_counter + 1) % 3
    if M._cache.check_counter == 0 then
      local has_file_changed = stat.mtime.sec > M._cache.last_modified
      if has_file_changed then
        M._cache.last_modified = stat.mtime.sec
        return true
      end
    end
  end

  if stat.mtime.sec > M._cache.last_modified then
    M._cache.last_modified = stat.mtime.sec
    return true
  end

  return false
end

function M._read_session(force_refresh)
  if not state.active_session then return nil end

  if not force_refresh and not M._should_refresh_content() and M._cache.output_lines then
    return M._cache.output_lines
  end

  local session_path = state.active_session.path
  local output_lines = formatter.format_session(session_path)
  M._cache.output_lines = output_lines
  return output_lines
end

function M._update_loading_animation(windows)
  if not M._animation.loading_line then return false end

  local buffer_line_count = vim.api.nvim_buf_line_count(windows.output_buf)
  if M._animation.loading_line <= 0 or M._animation.loading_line >= buffer_line_count then
    return false
  end

  local zero_index = M._animation.loading_line - 1
  local loading_text = LABELS.GENERATING_RESPONSE .. " " ..
      M._animation.frames[M._animation.current_frame]

  local ns_id = vim.api.nvim_create_namespace('loading_animation')
  vim.api.nvim_buf_clear_namespace(windows.output_buf, ns_id, zero_index, zero_index + 1)

  vim.api.nvim_buf_set_extmark(windows.output_buf, ns_id, zero_index, 0, {
    virt_text = { { loading_text, "Comment" } },
    virt_text_pos = "overlay",
    hl_mode = "replace"
  })

  return true
end

M._refresh_timer = nil

function M._start_content_refresh_timer(windows)
  if M._refresh_timer then
    pcall(vim.fn.timer_stop, M._refresh_timer)
    M._refresh_timer = nil
  end

  M._refresh_timer = vim.fn.timer_start(300, function()
    if state.goose_run_job then
      if M._should_refresh_content() then
        vim.schedule(function()
          local current_frame = M._animation.current_frame
          M.render(windows, true)
          M._animation.current_frame = current_frame
        end)
      end

      if state.goose_run_job then
        M._start_content_refresh_timer(windows)
      end
    else
      if M._refresh_timer then
        pcall(vim.fn.timer_stop, M._refresh_timer)
        M._refresh_timer = nil
      end
      vim.schedule(function() M.render(windows, true) end)
    end
  end)
end

function M._animate_loading(windows)
  local function start_animation_timer()
    if M._animation.timer then
      pcall(vim.fn.timer_stop, M._animation.timer)
      M._animation.timer = nil
    end

    M._animation.timer = vim.fn.timer_start(math.floor(1000 / M._animation.fps), function()
      M._animation.current_frame = (M._animation.current_frame % #M._animation.frames) + 1

      vim.schedule(function()
        M._update_loading_animation(windows)
      end)

      if state.goose_run_job then
        start_animation_timer()
      else
        M._animation.timer = nil
      end
    end)
  end

  M._start_content_refresh_timer(windows)

  start_animation_timer()
end

function M.render(windows, force_refresh)
  local function render()
    if not state.active_session and not state.new_session_name then
      return
    end

    if not force_refresh and state.goose_run_job and M._animation.loading_line then
      return
    end

    local output_lines = M._read_session(force_refresh)
    local is_new_session = state.new_session_name ~= nil

    if not output_lines then
      if is_new_session then
        output_lines = { "" }
      else
        return
      end
    else
      state.new_session_name = nil
    end

    M.handle_loading(windows, output_lines)

    M.write_output(windows, output_lines)

    M.handle_auto_scroll(windows)
  end
  render()
  require('goose.ui.mention').highlight_all_mentions(windows.output_buf)
  require('goose.ui.topbar').render()
  M.render_markdown()
end

function M.stop()
  if M._animation and M._animation.timer then
    pcall(vim.fn.timer_stop, M._animation.timer)
    M._animation.timer = nil
  end

  if M._refresh_timer then
    pcall(vim.fn.timer_stop, M._refresh_timer)
    M._refresh_timer = nil
  end

  M._animation.loading_line = nil
  M._cache = {
    last_modified = 0,
    output_lines = nil,
    session_path = nil,
    check_counter = 0
  }
end

function M.handle_loading(windows, output_lines)
  if state.goose_run_job then
    if #output_lines > 2 then
      for _, line in ipairs(formatter.separator) do
        table.insert(output_lines, line)
      end
    end

    -- Replace this line with our extmark animation
    local empty_loading_line = " " -- Just needs to be a non-empty string for the extmark to attach to
    table.insert(output_lines, empty_loading_line)
    table.insert(output_lines, "")

    M._animation.loading_line = #output_lines - 1

    -- Always ensure animation is running when there's an active job
    -- This is the key fix - we always start animation for an active job
    M._animate_loading(windows)

    vim.schedule(function()
      M._update_loading_animation(windows)
    end)
  else
    M._animation.loading_line = nil

    local ns_id = vim.api.nvim_create_namespace('loading_animation')
    vim.api.nvim_buf_clear_namespace(windows.output_buf, ns_id, 0, -1)

    if M._animation.timer then
      pcall(vim.fn.timer_stop, M._animation.timer)
      M._animation.timer = nil
    end

    if M._refresh_timer then
      pcall(vim.fn.timer_stop, M._refresh_timer)
      M._refresh_timer = nil
    end
  end
end

function M.write_output(windows, output_lines)
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', true)
  vim.api.nvim_buf_set_lines(windows.output_buf, 0, -1, false, output_lines)
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', false)
end

function M.handle_auto_scroll(windows)
  local line_count = vim.api.nvim_buf_line_count(windows.output_buf)
  local botline = vim.fn.line('w$', windows.output_win)

  local prev_line_count = vim.b[windows.output_buf].prev_line_count or 0
  vim.b[windows.output_buf].prev_line_count = line_count

  local was_at_bottom = (botline >= prev_line_count) or prev_line_count == 0

  if was_at_bottom then
    require("goose.ui.ui").scroll_to_bottom()
  end
end

return M

local M = {}

local state = require("goose.state")
local renderer = require('goose.ui.output_renderer')

function M.scroll_to_bottom()
  local line_count = vim.api.nvim_buf_line_count(state.windows.output_buf)
  vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 })

  vim.defer_fn(function()
    renderer.render_markdown()
  end, 200)
end

function M.close_windows(windows)
  if not windows then return end

  if M.is_goose_focused() then M.return_to_last_code_win() end

  renderer.stop()

  -- Close windows and delete buffers
  pcall(vim.api.nvim_win_close, windows.input_win, true)
  pcall(vim.api.nvim_win_close, windows.output_win, true)
  pcall(vim.api.nvim_buf_delete, windows.input_buf, { force = true })
  pcall(vim.api.nvim_buf_delete, windows.output_buf, { force = true })

  -- Clear autocmd groups
  pcall(vim.api.nvim_del_augroup_by_name, 'GooseResize')
  pcall(vim.api.nvim_del_augroup_by_name, 'GooseWindows')

  state.windows = nil
end

function M.return_to_last_code_win()
  local last_win = state.last_code_win_before_goose
  if last_win and vim.api.nvim_win_is_valid(last_win) then
    vim.api.nvim_set_current_win(last_win)
  end
end

function M.create_windows()
  local configurator = require("goose.ui.window_config")
  local input_buf = vim.api.nvim_create_buf(false, true)
  local output_buf = vim.api.nvim_create_buf(false, true)

  require('goose.ui.highlight').setup()

  local input_win = vim.api.nvim_open_win(input_buf, false, configurator.base_window_opts)
  local output_win = vim.api.nvim_open_win(output_buf, false, configurator.base_window_opts)
  local windows = {
    input_buf = input_buf,
    output_buf = output_buf,
    input_win = input_win,
    output_win = output_win
  }

  configurator.setup_options(windows)
  configurator.refresh_placeholder(windows)
  configurator.setup_autocmds(windows)
  configurator.setup_resize_handler(windows)
  configurator.setup_keymaps(windows)
  configurator.setup_after_actions(windows)
  configurator.configure_window_dimensions(windows)
  return windows
end

function M.focus_input(opts)
  opts = opts or {}
  local windows = state.windows
  vim.api.nvim_set_current_win(windows.input_win)

  if opts.restore_position and state.last_input_window_position then
    vim.api.nvim_win_set_cursor(0, state.last_input_window_position)
  end
end

function M.focus_output(opts)
  opts = opts or {}

  local windows = state.windows
  vim.api.nvim_set_current_win(windows.output_win)

  if opts.restore_position and state.last_output_window_position then
    vim.api.nvim_win_set_cursor(0, state.last_output_window_position)
  end
end

function M.is_goose_focused()
  if not state.windows then return false end
  -- are we in a goose window?
  local current_win = vim.api.nvim_get_current_win()
  return M.is_goose_window(current_win)
end

function M.is_goose_window(win)
  local windows = state.windows
  return win == windows.input_win or win == windows.output_win
end

function M.is_output_empty()
  local windows = state.windows
  if not windows or not windows.output_buf then return true end
  local lines = vim.api.nvim_buf_get_lines(windows.output_buf, 0, -1, false)
  return #lines == 0 or (#lines == 1 and lines[1] == "")
end

function M.clear_output()
  local windows = state.windows

  -- Clear any extmarks/namespaces first
  local ns_id = vim.api.nvim_create_namespace('loading_animation')
  vim.api.nvim_buf_clear_namespace(windows.output_buf, ns_id, 0, -1)

  -- Stop any running timers in the output module
  if renderer._animation.timer then
    pcall(vim.fn.timer_stop, renderer._animation.timer)
    renderer._animation.timer = nil
  end
  if renderer._refresh_timer then
    pcall(vim.fn.timer_stop, renderer._refresh_timer)
    renderer._refresh_timer = nil
  end

  -- Reset animation state
  renderer._animation.loading_line = nil

  -- Clear cache to force refresh on next render
  renderer._cache = {
    last_modified = 0,
    output_lines = nil,
    session_path = nil,
    check_counter = 0
  }

  -- Clear all buffer content
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', true)
  vim.api.nvim_buf_set_lines(windows.output_buf, 0, -1, false, {})
  vim.api.nvim_buf_set_option(windows.output_buf, 'modifiable', false)

  require('goose.ui.topbar').render()
  renderer.render_markdown()
end

function M.render_output()
  renderer.render(state.windows, false)
end

function M.stop_render_output()
  renderer.stop()
end

function M.toggle_fullscreen()
  local windows = state.windows
  if not windows then return end

  local ui_config = require("goose.config").get("ui")
  ui_config.fullscreen = not ui_config.fullscreen

  require("goose.ui.window_config").configure_window_dimensions(windows)
  require('goose.ui.topbar').render()

  if not M.is_goose_focused() then
    vim.api.nvim_set_current_win(windows.output_win)
  end
end

function M.select_session(sessions, cb)
  local util = require("goose.util")

  vim.ui.select(sessions, {
    prompt = "",
    format_item = function(session)
      local parts = {}

      if session.description then
        table.insert(parts, session.description)
      end

      if session.message_count then
        table.insert(parts, session.message_count .. " messages")
      end

      local modified = util.time_ago(session.modified)
      if modified then
        table.insert(parts, modified)
      end

      return table.concat(parts, " ~ ")
    end
  }, function(session_choice)
    cb(session_choice)
  end)
end

function M.toggle_pane()
  local current_win = vim.api.nvim_get_current_win()
  if current_win == state.windows.input_win then
    -- When moving from input to output, exit insert mode first
    vim.cmd('stopinsert')
    vim.api.nvim_set_current_win(state.windows.output_win)
  else
    -- When moving from output to input, just change window
    -- (don't automatically enter insert mode)
    vim.api.nvim_set_current_win(state.windows.input_win)

    -- Fix placeholder text when switching to input window
    local lines = vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false)
    if #lines == 1 and lines[1] == "" then
      -- Only show placeholder if the buffer is empty
      require('goose.ui.window_config').refresh_placeholder(state.windows)
    else
      -- Clear placeholder if there's text in the buffer
      vim.api.nvim_buf_clear_namespace(state.windows.input_buf, vim.api.nvim_create_namespace('input-placeholder'), 0, -1)
    end
  end
end

function M.write_to_input(text, windows)
  if not windows then windows = state.windows end
  if not windows then return end

  -- Check if input_buf is valid
  if not windows.input_buf or type(windows.input_buf) ~= "number" or not vim.api.nvim_buf_is_valid(windows.input_buf) then
    return
  end

  local lines

  -- Check if text is already a table/list of lines
  if type(text) == "table" then
    lines = text
  else
    -- If it's a string, split it into lines
    lines = {}
    for line in (text .. '\n'):gmatch('(.-)\n') do
      table.insert(lines, line)
    end

    -- If no newlines were found (empty result), use the original text
    if #lines == 0 then
      lines = { text }
    end
  end

  vim.api.nvim_buf_set_lines(windows.input_buf, 0, -1, false, lines)
end

return M

local M = {}

local Path = require('plenary.path')
local cached_history = nil
local prompt_before_history = nil

M.index = nil

local function get_history_file()
  local data_path = Path:new(vim.fn.stdpath('data')):joinpath('goose')
  if not data_path:exists() then
    data_path:mkdir({ parents = true })
  end
  return data_path:joinpath('history.txt')
end

M.write = function(prompt)
  local file = io.open(get_history_file().filename, "a")
  if file then
    -- Escape any newlines in the prompt
    local escaped_prompt = prompt:gsub("\n", "\\n")
    file:write(escaped_prompt .. "\n")
    file:close()
    -- Invalidate cache when writing new history
    cached_history = nil
  end
end

M.read = function()
  -- Return cached result if available
  if cached_history then
    return cached_history
  end

  local line_by_index = {}
  local file = io.open(get_history_file().filename, "r")

  if file then
    local lines = {}

    -- Read all non-empty lines
    for line in file:lines() do
      if line:gsub("%s", "") ~= "" then
        -- Unescape any escaped newlines
        local unescaped_line = line:gsub("\\n", "\n")
        table.insert(lines, unescaped_line)
      end
    end
    file:close()

    -- Reverse the array to have index 1 = most recent
    for i = 1, #lines do
      line_by_index[i] = lines[#lines - i + 1]
    end
  end

  -- Cache the result
  cached_history = line_by_index
  return line_by_index
end

M.prev = function()
  local history = M.read()

  if not M.index or M.index == 0 then
    prompt_before_history = require('goose.state').input_content
  end

  -- Initialize or increment index
  M.index = (M.index or 0) + 1

  -- Cap at the end of history
  if M.index > #history then
    M.index = #history
  end

  return history[M.index]
end

M.next = function()
  -- Return nil for invalid cases
  if not M.index then
    return nil
  end

  if M.index <= 1 then
    M.index = nil
    return prompt_before_history
  end

  M.index = M.index - 1
  return M.read()[M.index]
end

return M

local M = {}

local mentions_namespace = vim.api.nvim_create_namespace("GooseMentions")

function M.highlight_all_mentions(buf)
  -- Pattern for mentions
  local mention_pattern = "@[%w_%-%.][%w_%-%.]*"

  -- Clear existing extmarks
  vim.api.nvim_buf_clear_namespace(buf, mentions_namespace, 0, -1)

  -- Get all lines in buffer
  local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)

  for row, line in ipairs(lines) do
    local start_idx = 1
    -- Find all mentions in the line
    while true do
      local mention_start, mention_end = line:find(mention_pattern, start_idx)
      if not mention_start then break end

      -- Add extmark for this mention
      vim.api.nvim_buf_set_extmark(buf, mentions_namespace, row - 1, mention_start - 1, {
        end_col = mention_end,
        hl_group = "GooseMention",
      })

      -- Move to search for the next mention
      start_idx = mention_end + 1
    end
  end
end

local function insert_mention(windows, row, col, name)
  local current_line = vim.api.nvim_buf_get_lines(windows.input_buf, row - 1, row, false)[1]

  local insert_name = '@' .. name .. " "

  local new_line = current_line:sub(1, col) .. insert_name .. current_line:sub(col + 2)
  vim.api.nvim_buf_set_lines(windows.input_buf, row - 1, row, false, { new_line })

  -- Highlight all mentions in the updated buffer
  M.highlight_all_mentions(windows.input_buf)

  vim.defer_fn(function()
    vim.cmd('startinsert')
    vim.api.nvim_set_current_win(windows.input_win)
    vim.api.nvim_win_set_cursor(windows.input_win, { row, col + 1 + #insert_name + 1 })
  end, 100)
end

function M.mention(get_name)
  local windows = require('goose.state').windows

  local mention_key = require('goose.config').get('keymap').window.mention_file
  -- insert @ in case we just want the character
  if mention_key == '@' then
    vim.api.nvim_feedkeys('@', 'in', true)
  end

  local cursor_pos = vim.api.nvim_win_get_cursor(windows.input_win)
  local row, col = cursor_pos[1], cursor_pos[2]

  get_name(function(name)
    insert_mention(windows, row, col, name)
  end)
end

return M

local M = {}

local context_module = require('goose.context')

M.separator = {
  "---",
  ""
}

function M.format_session(session_path)
  if vim.fn.filereadable(session_path) == 0 then return nil end

  local session_lines = vim.fn.readfile(session_path)
  if #session_lines == 0 then return nil end

  local output_lines = { "" }

  local need_separator = false

  for i = 2, #session_lines do
    local success, message = pcall(vim.fn.json_decode, session_lines[i])
    if not success then goto continue end

    local message_lines = M._format_message(message)
    if message_lines then
      if need_separator then
        for _, line in ipairs(M.separator) do
          table.insert(output_lines, line)
        end
      else
        need_separator = true
      end

      vim.list_extend(output_lines, message_lines)
    end

    ::continue::
  end

  return output_lines
end

function M._format_user_message(lines, text)
  local context = context_module.extract_from_message(text)
  for _, line in ipairs(vim.split(context.prompt, "\n")) do
    table.insert(lines, "> " .. line)
  end

  if context.selected_text then
    table.insert(lines, "")
    for _, line in ipairs(vim.split(context.selected_text, "\n")) do
      table.insert(lines, line)
    end
  end
end

function M._format_message(message)
  if not message.content then return nil end

  local lines = {}
  local has_content = false

  for _, part in ipairs(message.content) do
    if part.type == 'text' and part.text and part.text ~= "" then
      local text = vim.trim(part.text)
      has_content = true

      if message.role == 'user' then
        M._format_user_message(lines, text)
      elseif message.role == 'assistant' then
        for _, line in ipairs(vim.split(text, "\n")) do
          table.insert(lines, line)
        end
      end
    elseif part.type == 'toolRequest' then
      if has_content then
        table.insert(lines, "")
      end
      M._format_tool(lines, part)
      has_content = true
    end
  end

  if has_content then
    table.insert(lines, "")
  end

  return has_content and lines or nil
end

function M._format_context(lines, type, value)
  if not type or not value then return end

  -- escape new lines
  value = value:gsub("\n", "\\n")

  local formatted_action = ' **' .. type .. '** ` ' .. value .. ' `'
  table.insert(lines, formatted_action)
end

function M._format_tool(lines, part)
  local tool = part.toolCall.value
  if not tool then return end

  if tool.name == 'developer__shell' then
    M._format_context(lines, '🚀 run', tool.arguments.command)
  elseif tool.name == 'developer__text_editor' then
    local path = tool.arguments.path
    local file_name = vim.fn.fnamemodify(path, ":t")

    if tool.arguments.command == 'str_replace' or tool.arguments.command == 'write' then
      M._format_context(lines, '✏ write to', file_name)
    elseif tool.arguments.command == 'view' then
      M._format_context(lines, '👀 view', file_name)
    else
      M._format_context(lines, '✨ command', tool.arguments.command)
    end
  else
    M._format_context(lines, '🔧 tool', tool.name)
  end
end

return M

local M = {}

local state = require("goose.state")

local LABELS = {
  NEW_SESSION_TITLE = "New session",
}

local function format_model_info()
  local info = require("goose.info").parse_goose_info()
  local config = require("goose.config").get()
  local parts = {}

  if config.ui.display_model then
    local model = info.goose_model and (info.goose_model:match("[^/]+$") or info.goose_model) or ""
    if model ~= "" then
      table.insert(parts, model)
    end
  end

  if config.ui.display_goose_mode then
    local mode = info.goose_mode
    if mode then
      table.insert(parts, "[" .. mode .. "]")
    end
  end

  return table.concat(parts, " ")
end

local function create_winbar_text(description, model_info, win_width)
  local available_width = win_width - 2 -- 2 padding spaces

  -- If total length exceeds available width, truncate description
  if #description + 1 + #model_info > available_width then
    local space_for_desc = available_width - #model_info - 4 -- -4 for "... "
    description = description:sub(1, space_for_desc) .. "... "
  end

  local padding = string.rep(" ", available_width - #description - #model_info)
  return string.format(" %s%s%s ", description, padding, model_info)
end

local function update_winbar_highlights(win_id)
  local current = vim.api.nvim_win_get_option(win_id, 'winhighlight')
  local parts = vim.split(current, ",")

  -- Remove any existing winbar highlights
  parts = vim.tbl_filter(function(part)
    return not part:match("^WinBar:") and not part:match("^WinBarNC:")
  end, parts)

  if not vim.tbl_contains(parts, "Normal:GooseNormal") then
    table.insert(parts, "Normal:GooseNormal")
  end

  table.insert(parts, "WinBar:GooseSessionDescription")
  table.insert(parts, "WinBarNC:GooseSessionDescription")

  vim.api.nvim_win_set_option(win_id, 'winhighlight', table.concat(parts, ","))
end

local function get_session_desc()
  local session_desc = LABELS.NEW_SESSION_TITLE

  if state.active_session then
    local session = require('goose.session').get_by_name(state.active_session.name)
    if session and session.description ~= "" then
      session_desc = session.description
    end
  end

  return session_desc
end

function M.render()
  local win = state.windows.output_win

  vim.schedule(function()
    vim.wo[win].winbar = create_winbar_text(
      get_session_desc(),
      format_model_info(),
      vim.api.nvim_win_get_width(win)
    )

    update_winbar_highlights(win)
  end)
end

return M

local M = {}

local state = require('goose.state')
local session_formatter = require('goose.ui.session_formatter')
local SEPARATOR_TEXT = session_formatter.separator[1]

local function re_focus()
  vim.cmd("normal! zt")
end

function M.goto_next_message()
  require('goose.ui.ui').focus_output()
  local windows = state.windows
  local win = windows.output_win
  local buf = windows.output_buf

  local current_line = vim.api.nvim_win_get_cursor(win)[1]
  local line_count = vim.api.nvim_buf_line_count(buf)

  for i = current_line, line_count do
    local line = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1]
    if line == SEPARATOR_TEXT then
      vim.api.nvim_win_set_cursor(win, { i + 1, 0 })
      re_focus()
      return
    end
  end
end

function M.goto_prev_message()
  require('goose.ui.ui').focus_output()
  local windows = state.windows
  local win = windows.output_win
  local buf = windows.output_buf
  local current_line = vim.api.nvim_win_get_cursor(win)[1]
  local current_message_start = nil

  -- Find if we're at a message start
  local at_message_start = false
  if current_line > 1 then
    local prev_line = vim.api.nvim_buf_get_lines(buf, current_line - 2, current_line - 1, false)[1]
    at_message_start = prev_line == SEPARATOR_TEXT
  end

  -- Find current message start
  for i = current_line - 1, 1, -1 do
    local line = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1]
    if line == SEPARATOR_TEXT then
      current_message_start = i + 1
      break
    end
  end

  -- Go to first line if no separator found
  if not current_message_start then
    vim.api.nvim_win_set_cursor(win, { 1, 0 })
    re_focus()
    return
  end

  -- If not at message start, go to current message start
  if not at_message_start and current_line > current_message_start then
    vim.api.nvim_win_set_cursor(win, { current_message_start, 0 })
    re_focus()
    return
  end

  -- Find previous message start
  for i = current_message_start - 2, 1, -1 do
    local line = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1]
    if line == SEPARATOR_TEXT then
      vim.api.nvim_win_set_cursor(win, { i + 1, 0 })
      re_focus()
      return
    end
  end

  -- If no previous message, go to first line
  vim.api.nvim_win_set_cursor(win, { 1, 0 })
  re_focus()
end

return M

local M = {}
local state = require("goose.state")
local context = require("goose.context")
local session = require("goose.session")
local ui = require("goose.ui.ui")
local job = require('goose.job')

function M.select_session()
  local all_sessions = session.get_all_workspace_sessions()
  local filtered_sessions = vim.tbl_filter(function(s)
    return s.description ~= '' and s ~= nil
  end, all_sessions)

  ui.select_session(filtered_sessions, function(selected_session)
    if not selected_session then return end
    state.active_session = selected_session
    if state.windows then
      ui.render_output()
      ui.scroll_to_bottom()
    else
      M.open()
    end
  end)
end

function M.open(opts)
  opts = opts or { focus = "input", new_session = false }

  if not M.goose_ok() then return end

  local are_windows_closed = state.windows == nil

  if are_windows_closed then
    state.windows = ui.create_windows()
  end

  if opts.new_session then
    state.active_session = nil
    state.last_sent_context = nil
    ui.clear_output()
  else
    if not state.active_session then
      state.active_session = session.get_last_workspace_session()
    end

    if are_windows_closed or ui.is_output_empty() then
      ui.render_output()
      ui.scroll_to_bottom()
    end
  end

  if opts.focus == "input" then
    ui.focus_input({ restore_position = are_windows_closed })
  elseif opts.focus == "output" then
    ui.focus_output({ restore_position = are_windows_closed })
  end
end

function M.run(prompt, opts)
  if not M.goose_ok() then return false end
  M.before_run(opts)

  -- Add small delay to ensure stop is complete
  vim.defer_fn(function()
    job.execute(prompt,
      {
        on_start = function()
          M.after_run(prompt)
        end,
        on_output = function(output)
          -- Reload all modified file buffers
          vim.cmd('checktime')

          -- for new sessions, session data can only be retrieved after running the command, retrieve once
          if not state.active_session and state.new_session_name then
            state.active_session = session.get_by_name(state.new_session_name)
          end
        end,
        on_error = function(err)
          vim.notify(
            err,
            vim.log.levels.ERROR
          )

          ui.close_windows(state.windows)
        end,
        on_exit = function()
          state.goose_run_job = nil
          require('goose.review').check_cleanup_breakpoint()
        end
      }
    )
  end, 10)
end

function M.after_run(prompt)
  require('goose.review').set_breakpoint()
  context.unload_attachments()
  state.last_sent_context = vim.deepcopy(context.context)
  require('goose.history').write(prompt)

  if state.windows then
    ui.render_output()
  end
end

function M.before_run(opts)
  M.stop()

  opts = opts or {}

  M.open({
    new_session = opts.new_session or not state.active_session,
  })

  -- sync session workspace to current workspace if there is missmatch
  if state.active_session then
    local session_workspace = state.active_session.workspace
    local current_workspace = vim.fn.getcwd()

    if session_workspace ~= current_workspace then
      session.update_session_workspace(state.active_session.name, current_workspace)
      state.active_session.workspace = current_workspace
    end
  end
end

function M.add_file_to_context()
  local picker = require('goose.ui.file_picker')
  require('goose.ui.mention').mention(function(mention_cb)
    picker.pick(function(file)
      mention_cb(file.name)
      context.add_file(file.path)
    end)
  end)
end

function M.configure_provider()
  local info_mod = require("goose.info")
  require("goose.provider").select(function(selection)
    if not selection then return end

    info_mod.set_config_value(info_mod.GOOSE_INFO.PROVIDER, selection.provider)
    info_mod.set_config_value(info_mod.GOOSE_INFO.MODEL, selection.model)

    if state.windows then
      require('goose.ui.topbar').render()
    else
      vim.notify("Changed provider to " .. selection.display, vim.log.levels.INFO)
    end
  end)
end

function M.stop()
  if (state.goose_run_job) then job.stop(state.goose_run_job) end
  state.goose_run_job = nil
  if state.windows then
    ui.stop_render_output()
    ui.render_output()
    ui.write_to_input({})
    require('goose.history').index = nil
  end
end

function M.goose_ok()
  if vim.fn.executable('goose') == 0 then
    vim.notify(
      "goose command not found - please install and configure goose before using this plugin",
      vim.log.levels.ERROR
    )
    return false
  end
  return true
end

return M

local M = {}

function M.get_all_sessions()
  local handle = io.popen('goose session list --format json')
  if not handle then return nil end

  local result = handle:read("*a")
  handle:close()

  local success, sessions = pcall(vim.fn.json_decode, result)
  if not success or not sessions or next(sessions) == nil then return nil end

  return vim.tbl_map(function(session)
    return {
      workspace = session.metadata.working_dir,
      description = session.metadata.description,
      message_count = session.metadata.message_count,
      tokens = session.metadata.total_tokens,
      modified = session.modified,
      name = session.id,
      path = session.path
    }
  end, sessions)
end

function M.get_all_workspace_sessions()
  local sessions = M.get_all_sessions()
  if not sessions then return nil end

  local workspace = vim.fn.getcwd()
  sessions = vim.tbl_filter(function(session)
    return session.workspace == workspace
  end, sessions)

  table.sort(sessions, function(a, b)
    return a.modified > b.modified
  end)

  return sessions
end

function M.get_last_workspace_session()
  local sessions = M.get_all_workspace_sessions()
  if not sessions then return nil end
  return sessions[1]
end

function M.get_by_name(name)
  local sessions = M.get_all_sessions()
  if not sessions then return nil end

  for _, session in ipairs(sessions) do
    if session.name == name then
      return session
    end
  end

  return nil
end

function M.update_session_workspace(session_name, workspace_path)
  local session = M.get_by_name(session_name)
  if not session then return false end

  local file = io.open(session.path, "r")
  if not file then return false end

  local first_line = file:read("*line")
  local rest = file:read("*all")
  file:close()

  -- Parse and update metadata
  local success, metadata = pcall(vim.fn.json_decode, first_line)
  if not success then return false end

  metadata.working_dir = workspace_path

  -- Write back: metadata line + rest of the file
  file = io.open(session.path, "w")
  if not file then return false end

  file:write(vim.fn.json_encode(metadata) .. "\n")
  if rest and rest ~= "" then
    file:write(rest)
  end
  file:close()

  return true
end

return M

local M = {}

function M.setup()
  vim.api.nvim_set_hl(0, 'GooseBorder', { fg = '#616161' })
  vim.api.nvim_set_hl(0, 'GooseBackground', { link = "Normal" })
  vim.api.nvim_set_hl(0, 'GooseSessionDescription', { link = "Comment" })
  vim.api.nvim_set_hl(0, "GooseMention", { link = "Special" })
end

return M

-- UI utility functions
local M = {}

-- Navigate through prompt history with consideration for multi-line input
-- Only triggers history navigation at text boundaries when using arrow keys
function M.navigate_history(direction, key, prev_history_fn, next_history_fn)
  return function()
    -- Check if using arrow keys
    local is_arrow_key = key == '<up>' or key == '<down>'

    if is_arrow_key then
      -- Get cursor position info
      local cursor_pos = vim.api.nvim_win_get_cursor(0)
      local current_line = cursor_pos[1]
      local line_count = vim.api.nvim_buf_line_count(0)

      -- Navigate history only at boundaries
      if (direction == 'prev' and current_line <= 1) or
          (direction == 'next' and current_line >= line_count) then
        if direction == 'prev' then prev_history_fn() else next_history_fn() end
      else
        -- Otherwise use normal navigation
        vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(key, true, false, true), 'n', false)
      end
    else
      -- Not arrow keys, always use history navigation
      if direction == 'prev' then prev_history_fn() else next_history_fn() end
    end
  end
end

return M

local core = require("goose.core")

local ui = require("goose.ui.ui")
local state = require("goose.state")
local review = require("goose.review")
local history = require("goose.history")

local M = {}

-- Core API functions

function M.open_input()
  core.open({ new_session = false, focus = "input" })
  vim.cmd('startinsert')
end

function M.open_input_new_session()
  core.open({ new_session = true, focus = "input" })
  vim.cmd('startinsert')
end

function M.open_output()
  core.open({ new_session = false, focus = "output" })
end

function M.close()
  ui.close_windows(state.windows)
end

function M.toggle()
  if state.windows == nil then
    local focus = state.last_focused_goose_window or "input"
    core.open({ new_session = false, focus = focus })
  else
    M.close()
  end
end

function M.toggle_focus()
  if not ui.is_goose_focused() then
    local focus = state.last_focused_goose_window or "input"
    core.open({ new_session = false, focus = focus })
  else
    ui.return_to_last_code_win()
  end
end

function M.change_mode(mode)
  local info_mod = require("goose.info")
  info_mod.set_config_value(info_mod.GOOSE_INFO.MODE, mode)

  if state.windows then
    require('goose.ui.topbar').render()
  else
    vim.notify('Goose mode changed to ' .. mode)
  end
end

function M.goose_mode_chat()
  M.change_mode(require('goose.info').GOOSE_MODE.CHAT)
end

function M.goose_mode_auto()
  M.change_mode(require('goose.info').GOOSE_MODE.AUTO)
end

function M.configure_provider()
  core.configure_provider()
end

function M.stop()
  core.stop()
end

function M.run(prompt)
  core.run(prompt, {
    ensure_ui = true,
    new_session = false,
    focus = "output"
  })
end

function M.run_new_session(prompt)
  core.run(prompt, {
    ensure_ui = true,
    new_session = true,
    focus = "output"
  })
end

function M.toggle_fullscreen()
  if not state.windows then
    core.open({ new_session = false, focus = "output" })
  end

  ui.toggle_fullscreen()
end

function M.select_session()
  core.select_session()
end

function M.toggle_pane()
  if not state.windows then
    core.open({ new_session = false, focus = "output" })
    return
  end

  ui.toggle_pane()
end

function M.diff_open()
  review.review()
end

function M.diff_next()
  review.next_diff()
end

function M.diff_prev()
  review.prev_diff()
end

function M.diff_close()
  review.close_diff()
end

function M.diff_revert_all()
  review.revert_all()
end

function M.diff_revert_this()
  review.revert_current()
end

function M.set_review_breakpoint()
  review.set_breakpoint()
end

function M.prev_history()
  local prev_prompt = history.prev()
  if prev_prompt then
    ui.write_to_input(prev_prompt)
  end
end

function M.next_history()
  local next_prompt = history.next()
  if next_prompt then
    ui.write_to_input(next_prompt)
  end
end

-- Command definitions that call the API functions
M.commands = {
  toggle = {
    name = "Goose",
    desc = "Open goose. Close if opened",
    fn = function()
      M.toggle()
    end
  },

  toggle_focus = {
    name = "GooseToggleFocus",
    desc = "Toggle focus between goose and last window",
    fn = function()
      M.toggle_focus()
    end
  },

  open_input = {
    name = "GooseOpenInput",
    desc = "Opens and focuses on input window on insert mode",
    fn = function()
      M.open_input()
    end
  },

  open_input_new_session = {
    name = "GooseOpenInputNewSession",
    desc = "Opens and focuses on input window on insert mode. Creates a new session",
    fn = function()
      M.open_input_new_session()
    end
  },

  open_output = {
    name = "GooseOpenOutput",
    desc = "Opens and focuses on output window",
    fn = function()
      M.open_output()
    end
  },

  close = {
    name = "GooseClose",
    desc = "Close UI windows",
    fn = function()
      M.close()
    end
  },

  stop = {
    name = "GooseStop",
    desc = "Stop goose while it is running",
    fn = function()
      M.stop()
    end
  },

  toggle_fullscreen = {
    name = "GooseToggleFullscreen",
    desc = "Toggle between normal and fullscreen mode",
    fn = function()
      M.toggle_fullscreen()
    end
  },

  select_session = {
    name = "GooseSelectSession",
    desc = "Select and load a goose session",
    fn = function()
      M.select_session()
    end
  },

  toggle_pane = {
    name = "GooseTogglePane",
    desc = "Toggle between input and output panes",
    fn = function()
      M.toggle_pane()
    end
  },

  goose_mode_chat = {
    name = "GooseModeChat",
    desc = "Set goose mode to `chat`. (Tool calling disabled. No editor context besides selections)",
    fn = function()
      M.goose_mode_chat()
    end
  },

  goose_mode_auto = {
    name = "GooseModeAuto",
    desc = "Set goose mode to `auto`. (Default mode with full agent capabilities)",
    fn = function()
      M.goose_mode_auto()
    end
  },

  configure_provider = {
    name = "GooseConfigureProvider",
    desc = "Quick provider and model switch from predefined list",
    fn = function()
      M.configure_provider()
    end
  },

  run = {
    name = "GooseRun",
    desc = "Run goose with a prompt (continue last session)",
    fn = function(opts)
      M.run(opts.args)
    end
  },

  run_new_session = {
    name = "GooseRunNewSession",
    desc = "Run goose with a prompt (new session)",
    fn = function(opts)
      M.run_new_session(opts.args)
    end
  },

  -- Updated diff command names
  diff_open = {
    name = "GooseDiff",
    desc = "Opens a diff tab of a modified file since the last goose prompt",
    fn = function()
      M.diff_open()
    end
  },

  diff_next = {
    name = "GooseDiffNext",
    desc = "Navigate to next file diff",
    fn = function()
      M.diff_next()
    end
  },

  diff_prev = {
    name = "GooseDiffPrev",
    desc = "Navigate to previous file diff",
    fn = function()
      M.diff_prev()
    end
  },

  diff_close = {
    name = "GooseDiffClose",
    desc = "Close diff view tab and return to normal editing",
    fn = function()
      M.diff_close()
    end
  },

  diff_revert_all = {
    name = "GooseRevertAll",
    desc = "Revert all file changes since the last goose prompt",
    fn = function()
      M.diff_revert_all()
    end
  },

  diff_revert_this = {
    name = "GooseRevertThis",
    desc = "Revert current file changes since the last goose prompt",
    fn = function()
      M.diff_revert_this()
    end
  },

  set_review_breakpoint = {
    name = "GooseSetReviewBreakpoint",
    desc = "Set a review breakpoint to track changes",
    fn = function()
      M.set_review_breakpoint()
    end
  },
}

function M.setup()
  -- Register commands without arguments
  for key, cmd in pairs(M.commands) do
    if key ~= "run" and key ~= "run_new_session" then
      vim.api.nvim_create_user_command(cmd.name, cmd.fn, {
        desc = cmd.desc
      })
    end
  end

  -- Register commands with arguments
  vim.api.nvim_create_user_command(M.commands.run.name, M.commands.run.fn, {
    desc = M.commands.run.desc,
    nargs = "+"
  })

  vim.api.nvim_create_user_command(M.commands.run_new_session.name, M.commands.run_new_session.fn, {
    desc = M.commands.run_new_session.desc,
    nargs = "+"
  })
end

return M

local M = {}

function M.select(cb)
  local config = require("goose.config")

  -- Create a flat list of all provider/model combinations
  local model_options = {}

  for provider, models in pairs(config.get("providers")) do
    for _, model in ipairs(models) do
      table.insert(model_options, {
        provider = provider,
        model = model,
        display = provider .. ": " .. model
      })
    end
  end

  if #model_options == 0 then
    vim.notify("No models configured in providers", vim.log.levels.ERROR)
    return
  end

  vim.ui.select(model_options, {
    prompt = "Select model:",
    format_item = function(item)
      return item.display
    end
  }, function(selection)
    cb(selection)
  end)
end

return M

local api = require("goose.api")

local M = {}

-- Binds a keymap config with its api fn
-- Name of api fn & keymap global config should always be the same
function M.setup(keymap)
  local cmds = api.commands
  local global = keymap.global

  for key, mapping in pairs(global) do
    if type(mapping) == "string" then
      vim.keymap.set(
        { 'n', 'v' },
        mapping,
        function() api[key]() end,
        { silent = false, desc = cmds[key] and cmds[key].desc }
      )
    end
  end
end

return M

-- goose.nvim/lua/goose/job.lua
-- Contains goose job execution logic

local context = require("goose.context")
local state = require("goose.state")
local Job = require('plenary.job')
local util = require("goose.util")

local M = {}

function M.build_args(prompt)
  if not prompt then return nil end
  local message = context.format_message(prompt)
  local args = { "run", "--text", message }

  if state.active_session then
    table.insert(args, "--name")
    table.insert(args, state.active_session.name)
    table.insert(args, "--resume")
  else
    local session_name = util.uid()
    state.new_session_name = session_name
    table.insert(args, "--name")
    table.insert(args, session_name)
  end

  return args
end

function M.execute(prompt, handlers)
  if not prompt then
    return nil
  end

  local args = M.build_args(prompt)

  state.goose_run_job = Job:new({
    command = 'goose',
    args = args,
    on_start = function()
      vim.schedule(function()
        handlers.on_start()
      end)
    end,
    on_stdout = function(_, out)
      if out then
        vim.schedule(function()
          handlers.on_output(out)
        end)
      end
    end,
    on_stderr = function(_, err)
      if err then
        vim.schedule(function()
          handlers.on_error(err)
        end)
      end
    end,
    on_exit = function()
      vim.schedule(function()
        handlers.on_exit()
      end)
    end
  })

  state.goose_run_job:start()
end

function M.stop(job)
  if job then
    pcall(function()
      vim.uv.process_kill(job.handle)
      job:shutdown()
    end)
  end
end

return M

-- Plugin loader for goose.nvim
if vim.g.loaded_goose_nvim == 1 then
  return
end
vim.g.loaded_goose_nvim = 1

local M = {}
local config = require("goose.config")
local keymap = require("goose.keymap")
local api = require("goose.api")

function M.setup(opts)
  config.setup(opts)
  api.setup()
  keymap.setup(config.get("keymap"))
end

return M

local M = {}

-- ui
M.windows = nil
M.input_content = {}
M.last_focused_goose_window = nil
M.last_input_window_position = nil
M.last_output_window_position = nil
M.last_code_win_before_goose = nil

-- context
M.last_sent_context = nil

-- session
M.active_session = nil
M.new_session_name = nil

-- job
M.goose_run_job = nil

return M

This code segment functions to: Convert a timestamp into a human-readable relative time format by calculating the difference between the current time and the specified datetime, while accounting for timezone offsets and providing granular time interval descriptions.

```lua
[TASK_BEGIN]
function M.time_ago(dateTime)
  -- Parse datetime components and timezone
  local date_part, time_part, timezone_str = dateTime:match("^(%d+-%d+-%d+)%s+(%d+:%d+:%d+)%s+(%a+)$")
  if not date_part or not time_part or not timezone_str then return "invalid date format" end

  -- Extract date components
  local year, month, day = date_part:match("^(%d+)-(%d+)-(%d+)$")
  year, month, day = tonumber(year), tonumber(month), tonumber(day)

  -- Extract time components
  local hour, min, sec = time_part:match("^(%d+):(%d+):(%d+)$")
  hour, min, sec = tonumber(hour), tonumber(min), tonumber(sec)

  -- Create timestamp from components (interpreted as local time)
  local time_table = {year = year, month = month, day = day, hour = hour, min = min, sec = sec}
  local timestamp = os.time(time_table)
  if not timestamp then return "invalid date/time" end

  -- Adjust for timezone offset to get UTC timestamp
  local tz_offset = M.get_timezone_offset(timezone_str)
  local utc_timestamp = timestamp - tz_offset

  -- Calculate time difference
  local current_ts = os.time()
  local diff = current_ts - utc_timestamp

  -- Handle future times
  if diff < 0 then return "in the future" end

  -- Time intervals in seconds
  local intervals = {
    {name = "year",   duration = 31536000},  -- 365 days
    {name = "month",  duration = 2592000},   -- 30 days
    {name = "day",    duration = 86400},
    {name = "hour",   duration = 3600},
    {name = "minute", duration = 60},
    {name = "second", duration = 1}
  }

  -- Find largest suitable interval
  for _, interval in ipairs(intervals) do
    local count = math.floor(diff / interval.duration)
    if count > 0 then
      return string.format("%d %s%s ago",
        count,
        interval.name,
        count ~= 1 and "s" or "")
    end
  end

  return "just now"
end
[TASK_END]
```

function M.time_ago(dateTime)
  local date_part, time_part, timezone_str = dateTime:match("^(%d+-%d+-%d+)%s+(%d+:%d+:%d+)%s+(%a+)$")
  if not date_part or not time_part or not timezone_str then return "invalid date format" end
  local year, month, day = date_part:match("^(%d+)-(%d+)-(%d+)$")
  year, month, day = tonumber(year), tonumber(month), tonumber(day)
  local hour, min, sec = time_part:match("^(%d+):(%d+):(%d+)$")
  hour, min, sec = tonumber(hour), tonumber(min), tonumber(sec)
  local time_table = {year = year, month = month, day = day, hour = hour, min = min, sec = sec}
  local timestamp = os.time(time_table)
  if not timestamp then return "invalid date/time" end
  local tz_offset = M.get_timezone_offset(timezone_str)
  local utc_timestamp = timestamp - tz_offset
  local current_ts = os.time()
  local diff = current_ts - utc_timestamp
  if diff < 0 then return "in the future" end
  local intervals = {
    {name = "year",   duration = 31536000},
    {name = "month",  duration = 2592000},
    {name = "day",    duration = 86400},
    {name = "hour",   duration = 3600},
    {name = "minute", duration = 60},
    {name = "second", duration = 1}
  }
  for _, interval in ipairs(intervals) do
    local count = math.floor(diff / interval.duration)
    if count > 0 then
      return string.format("%d %s%s ago",
        count,
        interval.name,
        count ~= 1 and "s" or "")
    end
  end
  return "just now"
end