local List = require("gitlab.utils.list")
local u = require("gitlab.utils")
local state = require("gitlab.state")
local M = {}

---@class Hunk
---@field old_line integer
---@field old_range integer
---@field new_line integer
---@field new_range integer

---@class HunksAndDiff
---@field hunks Hunk[] list of hunks
---@field all_diff_output table The data from the git diff command

---Turn hunk line into Lua table
---@param line table
---@return Hunk|nil
M.parse_possible_hunk_headers = function(line)
  if line:sub(1, 2) == "@@" then
    -- match:
    --  @@ -23 +23 @@ ...
    --  @@ -23,0 +23 @@ ...
    --  @@ -41,0 +42,4 @@ ...
    local old_start, old_range, new_start, new_range = line:match("@@+ %-(%d+),?(%d*) %+(%d+),?(%d*) @@+")

    return {
      old_line = tonumber(old_start),
      old_range = tonumber(old_range) or 0,
      new_line = tonumber(new_start),
      new_range = tonumber(new_range) or 0,
    }
  end
end
---@param linnr number
---@param hunk Hunk
---@param all_diff_output table
---@return boolean
local line_was_removed = function(linnr, hunk, all_diff_output)
  for matching_line_index, line in ipairs(all_diff_output) do
    local found_hunk = M.parse_possible_hunk_headers(line)
    if found_hunk ~= nil and vim.deep_equal(found_hunk, hunk) then
      -- We found a matching hunk, now we need to iterate over the lines from the raw diff output
      -- at that hunk until we reach the line we are looking for. When the indexes match we check
      -- to see if that line is deleted or not.
      for hunk_line_index = found_hunk.old_line, hunk.old_line + hunk.old_range, 1 do
        local line_content = all_diff_output[matching_line_index + 1]
        if hunk_line_index == linnr then
          if string.match(line_content, "^%-") then
            return true
          end
        end
      end
    end
  end
  return false
end

---@param linnr number
---@param hunk Hunk
---@param all_diff_output table
---@return boolean
local line_was_added = function(linnr, hunk, all_diff_output)
  for matching_line_index, line in ipairs(all_diff_output) do
    local found_hunk = M.parse_possible_hunk_headers(line)
    if found_hunk ~= nil and vim.deep_equal(found_hunk, hunk) then
      -- Parse the lines from the hunk and return only the added lines
      local hunk_lines = {}
      local i = 1
      local line_content = all_diff_output[matching_line_index + i]
      while line_content ~= nil and line_content:sub(1, 2) ~= "@@" do
        if string.match(line_content, "^%+") then
          table.insert(hunk_lines, line_content)
        end
        i = i + 1
        line_content = all_diff_output[matching_line_index + i]
      end

      -- We are only looking at added lines in the changed hunk to see if their index
      -- matches the index of a line that was added
      local starting_index = found_hunk.new_line - 1 -- The "+j" will add one
      for j, _ in ipairs(hunk_lines) do
        if (starting_index + j) == linnr then
          return true
        end
      end
    end
  end
  return false
end

---Parse git diff hunks.
---@param base_sha string Git base SHA of merge request.
---@return HunksAndDiff
local parse_hunks_and_diff = function(base_sha)
  local hunks = {}
  local all_diff_output = {}

  local reviewer = require("gitlab.reviewer")
  local cmd = {
    "diff",
    "--minimal",
    "--unified=0",
    "--no-color",
    base_sha,
    "--",
    reviewer.get_current_file_oldpath(),
    reviewer.get_current_file_path(),
  }

  local Job = require("plenary.job")
  local diff_job = Job:new({
    command = "git",
    args = cmd,
    on_exit = function(j, return_code)
      if return_code == 0 then
        all_diff_output = j:result()
        for _, line in ipairs(all_diff_output) do
          local hunk = M.parse_possible_hunk_headers(line)
          if hunk ~= nil then
            table.insert(hunks, hunk)
          end
        end
      else
        M.notify("Failed to get git diff: " .. j:stderr(), vim.log.levels.WARN)
      end
    end,
  })

  diff_job:sync()

  return { hunks = hunks, all_diff_output = all_diff_output }
end

-- Parses the lines from a diff and returns the
-- index of the next hunk, when provided an initial index
---@param lines table
---@param i integer
---@return integer|nil
local next_hunk_index = function(lines, i)
  for j, line in ipairs(lines) do
    local hunk = M.parse_possible_hunk_headers(line)
    if hunk ~= nil and j > i then
      return j
    end
  end
  return nil
end

--- Processes the number of changes until the target is reached. This returns
--- a negative or positive number indicating the number of lines in the hunk
--- that have been added or removed prior to the target line
---@param line_number number
---@param hunk Hunk
---@param lines table
---@return integer
local net_changed_in_hunk_before_line = function(line_number, hunk, lines)
  local net_lines = 0
  local current_line_old = hunk.old_line

  for _, line in ipairs(lines) do
    if line:sub(1, 1) == "-" then
      if current_line_old < line_number then
        net_lines = net_lines - 1
      end
      current_line_old = current_line_old + 1
    elseif line:sub(1, 1) == "+" then
      if current_line_old < line_number then
        net_lines = net_lines + 1
      end
    else
      current_line_old = current_line_old + 1
    end
  end

  return net_lines
end

---Counts the total number of changes in a set of lines, can be positive if added lines or negative if removed lines
---@param lines table
---@return integer
local count_changes = function(lines)
  local total = 0
  for _, line in ipairs(lines) do
    if line:match("^%+") then
      total = total + 1
    else
      total = total - 1
    end
  end
  return total
end

---@param new_line number|nil
---@param hunks Hunk[]
---@param all_diff_output table
---@return string|nil
local function get_modification_type_from_new_sha(new_line, hunks, all_diff_output)
  if new_line == nil then
    return nil
  end
  return List.new(hunks):find(function(hunk)
    local new_line_end = hunk.new_line + hunk.new_range - (hunk.new_range > 0 and 1 or 0)
    local in_new_range = new_line >= hunk.new_line and new_line <= new_line_end
    local is_range_zero = hunk.new_range == 0 and hunk.old_range == 0
    return in_new_range and (is_range_zero or line_was_added(new_line, hunk, all_diff_output))
  end) and "added" or "bad_file_unmodified"
end

---@param old_line number|nil
---@param new_line number|nil
---@param hunks Hunk[]
---@param all_diff_output table
---@return string|nil
local function get_modification_type_from_old_sha(old_line, new_line, hunks, all_diff_output)
  if old_line == nil then
    return nil
  end

  return List.new(hunks):find(function(hunk)
    local old_line_end = hunk.old_line + hunk.old_range - (hunk.old_range > 0 and 1 or 0)
    local new_line_end = hunk.new_line + hunk.new_range - (hunk.new_range > 0 and 1 or 0)
    local in_old_range = old_line >= hunk.old_line and old_line <= old_line_end
    local in_new_range = new_line >= hunk.new_line and new_line <= new_line_end
    return (in_old_range or in_new_range) and line_was_removed(old_line, hunk, all_diff_output)
  end) and "deleted" or "unmodified"
end

---Returns whether the comment is on a deleted line, added line, or unmodified line.
---This is in order to build the payload for Gitlab correctly by setting the old line and new line.
---@param old_line number|nil
---@param new_line number|nil
---@param new_sha_focused boolean
---@return string|nil
function M.get_modification_type(old_line, new_line, new_sha_focused)
  local hunk_and_diff_data = parse_hunks_and_diff(state.INFO.diff_refs.base_sha)
  if hunk_and_diff_data.hunks == nil then
    return
  end

  local hunks = hunk_and_diff_data.hunks
  local all_diff_output = hunk_and_diff_data.all_diff_output
  return new_sha_focused and get_modification_type_from_new_sha(new_line, hunks, all_diff_output)
    or get_modification_type_from_old_sha(old_line, new_line, hunks, all_diff_output)
end

---Returns the matching line number of a line in the new/old version of the file compared to the current SHA.
---@param old_sha string
---@param new_sha string
---@param file_path string
---@param old_file_path string
---@param line_number number
---@return number|nil
M.calculate_matching_line_new = function(old_sha, new_sha, file_path, old_file_path, line_number)
  local net_change = 0
  local diff_cmd = string.format(
    "git diff --minimal --unified=0 --no-color %s %s -- %s %s",
    old_sha,
    new_sha,
    old_file_path,
    file_path
  )

  local handle = io.popen(diff_cmd)
  if handle == nil then
    u.notify(string.format("Error running git diff command for %s", file_path), vim.log.levels.ERROR)
    return nil
  end

  local all_lines = List.new({})
  for line in handle:lines() do
    table.insert(all_lines, line)
  end

  for i, line in ipairs(all_lines) do
    local hunk = M.parse_possible_hunk_headers(line)
    if hunk ~= nil then
      if line_number <= hunk.old_line then
        -- We have reached a hunk which starts after our target, return the changed total lines
        return line_number + net_change
      end

      local n = next_hunk_index(all_lines, i) or #all_lines
      local diff_lines = all_lines:slice(i + 1, n - 1)

      -- If the line is IN the hunk, process the hunk and return the change until that line
      if line_number >= hunk.old_line and line_number < hunk.old_line + hunk.old_range then
        net_change = line_number + net_change + net_changed_in_hunk_before_line(line_number, hunk, diff_lines)
        return net_change
      end

      -- If it's not it's after this hunk, just add all the changes and keep iterating
      net_change = net_change + count_changes(diff_lines)
    end
  end

  -- TODO: Possibly handle lines that are out of range in the new files
  return line_number + net_change + 1
end

return M
