-- mod-version:3
local core = require "core"
local config = require "core.config"
local command = require "core.command"
local common = require "core.common"
local keymap = require "core.keymap"
local style = require "core.style"
local DocView = require "core.docview"
local Doc = require "core.doc"
local View = require "core.view"

---Configuration options for `diffview` plugin.
---@class config.plugins.diffview
---Logs the amount of time taken to recompute differences.
---@field log_times boolean
---Disable syntax coloring on changed lines to improve visibility.
---@field plain_text boolean
---The color used on changed lines when plain text is enabled.
---@field plain_text_color renderer.color
config.plugins.diffview = common.merge({
  log_times = false,
  plain_text = false,
  plain_text_color = { common.color "#ffffff" },
  config_spec = {
    name = "Differences Viewer",
    {
      label = "Log Times",
      description = "Logs the amount of time taken to compute differences.",
      path = "log_times",
      type = "toggle",
      default = false
    },
    {
      label = "Plain Text",
      description = "Disable syntax coloring on changed lines to improve visibility.",
      path = "plain_text",
      type = "toggle",
      default = false
    },
    {
      label = "Plain Text Color",
      description = "The color used on changed lines when plain text is enabled.",
      path = "plain_text_color",
      type = "color",
      default = { common.color "#ffffff" }
    }
  }
}, config.plugins.diffview)

---@type string?
local element_a = nil
---@type string?
local element_b = nil
---@type string?
local element_a_text = nil
---@type string?
local element_b_text = nil
---@type integer
local diff_updater_idx = 0

---@class plugins.diffview.view : core.view
---@field super core.view
---@field doc_view_a core.docview
---@field doc_view_b core.docview
---@field a_changes diff.changes[]
---@field b_changes diff.changes[]
---@field a_gaps table<integer,table<integer,integer>>
---@field b_gaps table<integer,table<integer,integer>>
---@field compare_type plugins.diffview.view.type
---@field hovered_sync? plugins.diffview.view.hovered_sync
---@overload fun(a:string,b:string,ct?:plugins.diffview.view.type,names?:plugins.diffview.view.string_names):plugins.diffview.view
local DiffView = View:extend()

---@enum plugins.diffview.view.type
DiffView.type = {
  STRING_FILE = 1,
  FILE_STRING = 2,
  FILE_FILE = 3,
  STRING_STRING = 4
}

---Represents the active sync indicator.
---@class plugins.diffview.view.hovered_sync
---@field is_a boolean
---@field line integer
---@field target_line integer

---Names used when a or b are not files.
---@class plugins.diffview.view.string_names
---@field a? string
---@field b? string

---Constructor
---@param a string
---@param b string
---@param compare_type? plugins.diffview.view.type
---@param names? plugins.diffview.view.string_names
function DiffView:new(a, b, compare_type, names)
  DiffView.super.new(self)

  self.scrollable = true
  self.compare_type = compare_type or DiffView.type.STRING_STRING
  self.hovered_sync = nil
  self.skip_update_diff = false

  names = names or {}

  local doc_a, doc_b
  if compare_type == DiffView.type.FILE_FILE then
    doc_a = Doc(common.basename(a), a)
    doc_b = Doc(common.basename(b), b)
  elseif compare_type == DiffView.type.STRING_STRING then
    doc_a = Doc(names.a, names.a, true)
    if a ~= "" then doc_a:insert(1, 1, a) doc_a:clear_undo_redo() end
    doc_b = Doc(names.b, names.b, true)
    if b ~= "" then doc_b:insert(1, 1, b) doc_b:clear_undo_redo() end
  elseif compare_type == DiffView.type.STRING_FILE then
    doc_a = Doc(names.a, names.a, true)
    if a ~= "" then doc_a:insert(1, 1, a) doc_a:clear_undo_redo() end
    doc_b = Doc(common.basename(b), b)
  elseif compare_type == DiffView.type.FILE_STRING then
    doc_a = Doc(common.basename(a), a)
    doc_b = Doc(names.b, names.b, true)
    if b ~= "" then doc_b:insert(1, 1, b) doc_b:clear_undo_redo() end
  end

  self.doc_view_a = DocView(doc_a)
  self.doc_view_b = DocView(doc_b)

  self.doc_view_a.diff_view_parent = self
  self.doc_view_b.diff_view_parent = self
  self.doc_view_a.code_folding_disabled = true
  self.doc_view_b.code_folding_disabled = true

  self.a_gaps = {}
  self.b_gaps = {}
  self.a_changes = {}
  self.b_changes = {}
  self.views_patched = false

  self:patch_views()
  self:update_diff()

  self.v_scrollbar.contracted_size = style.expanded_scrollbar_size * 2
  self.v_scrollbar.expanded_size = style.expanded_scrollbar_size * 2
end

function DiffView:get_name()
  if self.compare_type == DiffView.type.FILE_FILE then
    return "Files Comparison"
  elseif self.compare_type == DiffView.type.STRING_STRING then
    return "Strings Comparison"
  elseif self.compare_type == DiffView.type.FILE_STRING then
    return "File->String Comparison"
  elseif self.compare_type == DiffView.type.STRING_FILE then
    return "String->File Comparison"
  end
  return "Diff Viewer"
end

---Updates the registered differences between current side A and B.
function DiffView:update_diff()
  if self.skip_update_diff then self.skip_update_diff = false return end

  -- stop previous update if still running.
  if self.updater_idx then
    for _, thread in pairs(core.threads) do
      if thread.diff_viewer and thread.diff_viewer == self.updater_idx then
        thread.cr = coroutine.create(function() end)
      end
    end
  end

  local start_time = system.get_time()

  if config.plugins.diffview.log_times then
    core.log(
      (#self.a_changes == 0 and "Computing " or "Recomputing ")
      .. "differences..."
    )
  end

  local idx = core.add_thread(function()
    local ai, bi = 1, 1
    local a_offset, b_offset = 0, 0
    local a_offset_total, b_offset_total = 0, 0
    local a_len = #self.doc_view_a.doc.lines
    local b_len = #self.doc_view_b.doc.lines

    local computing_start = system.get_time()
    local a_gaps = #self.a_gaps == 0 and self.a_gaps or {}
    local b_gaps = #self.b_gaps == 0 and self.b_gaps or {}
    local a_changes = #self.a_changes == 0 and self.a_changes or {}
    local b_changes = #self.b_changes == 0 and self.b_changes or {}
    for edit in diff.diff_iter(self.doc_view_a.doc.lines, self.doc_view_b.doc.lines) do
      if edit.tag == "equal" or edit.tag == "modify" then
        -- Assign gaps for this line
        a_gaps[ai] = { a_offset, a_offset_total }
        b_gaps[bi] = { b_offset, b_offset_total }

        -- Insert inline diffs if present
        if edit.a then
          table.insert(a_changes, {
            tag = edit.tag,
            changes = diff.inline_diff(edit.b or "", edit.a)
          })
          ai = ai + 1
          a_offset = 0
        end
        if edit.b then
          table.insert(b_changes, {
            tag = edit.tag,
            changes = diff.inline_diff(edit.a or "", edit.b)
          })
          bi = bi + 1
          b_offset = 0
        end

      elseif edit.tag == "delete" then
        -- Lines only in A (deleted from B)
        if edit.a then
          a_gaps[ai] = { a_offset, a_offset_total }
          table.insert(a_changes, { tag = "delete" })
          ai = ai + 1
          -- Increase gap on B side because these lines are missing in B
          b_offset = b_offset + 1
          b_offset_total = b_offset_total + 1
        end

      elseif edit.tag == "insert" then
        -- Lines only in B (inserted in B)
        if edit.b then
          b_gaps[bi] = { b_offset, b_offset_total }
          table.insert(b_changes, { tag = "insert" })
          bi = bi + 1
          -- Increase gap on A side because these lines are missing in A
          a_offset = a_offset + 1
          a_offset_total = a_offset_total + 1
        end
      end

      if system.get_time() - computing_start >= 0.5 then
        coroutine.yield()
        computing_start = system.get_time()
      end
    end

    -- Fill trailing lines spaces after diff ends
    while ai <= a_len do
      a_gaps[ai] = a_gaps[ai] or { a_offset, a_offset_total }
      ai = ai + 1
    end
    while bi <= b_len do
      b_gaps[bi] = b_gaps[bi] or { b_offset, b_offset_total }
      bi = bi + 1
    end

    self.a_gaps = a_gaps
    self.b_gaps = b_gaps
    self.a_changes = a_changes
    self.b_changes = b_changes

    self.updater_idx = nil

    self.doc_view_b.scroll.to.y = self.doc_view_a.scroll.y
    self.doc_view_b.scroll.y = self.doc_view_a.scroll.y

    if config.plugins.diffview.log_times then
      core.log(
        "Finished computing differences in %.2fs",
        system.get_time() - start_time
      )
    end
  end)

  core.threads[idx].diff_viewer = diff_updater_idx
  self.updater_idx = diff_updater_idx
  diff_updater_idx = diff_updater_idx + 1
end

function DiffView:sync(line, target_line, is_a)
  local changes = is_a and self.a_changes or self.b_changes
  local target_changes = is_a and self.b_changes or self.a_changes
  local target_gaps = is_a and self.b_gaps or self.a_gaps

  ---@type core.docview
  local from = is_a and self.doc_view_a or self.doc_view_b
  ---@type core.docview
  local to = is_a and self.doc_view_b or self.doc_view_a

  local l = line
  local tag = changes[l].tag
  local text = ""
  local total = 0
  while changes[l] and changes[l].tag == tag do
    total = total + 1
    changes[l] = {tag = "equal"}
    text = text .. from.doc.lines[l]
    l = l + 1
  end
  if tag == "modify" then
    to.doc:set_selection(target_line, 1, target_line+total-1, math.huge)
    to.doc:replace(function() return text:sub(1, #text-1) end)
    for i=target_line, target_line+total-1 do
      target_changes[i] = {tag = "equal"}
    end
  else
    if line == 1 and target_line == 1 then
      to.doc:insert(target_line, 1, text)
      target_line = target_line - 1
    else
      to.doc:insert(target_line, math.huge, "\n" .. text:sub(1, #text - 1))
    end

    -- update target changes and target gaps
    local changes_inserts = {}

    for _=1, total do
      table.insert(changes_inserts, {tag = "equal"})
    end

    common.splice(target_changes, target_line, 0, changes_inserts)

    local gaps_inserts = {}
    local gaps = {0, 0}

    if target_gaps[target_line+1] then
      gaps = {0, target_gaps[target_line+1][2] - total}
      target_gaps[target_line+1] = {table.unpack(gaps)}
      for i=target_line+2, #target_gaps do
        target_gaps[i][2] = target_gaps[i][2] - total
      end
    end

    for _=1, total do
      table.insert(gaps_inserts, {table.unpack(gaps)})
    end

    common.splice(target_gaps, target_line, 0, gaps_inserts)
  end
end

function DiffView:sync_selected()
  local view, changes, to_view, is_a

  if core.active_view == self.doc_view_a then
    view = self.doc_view_a
    to_view = self.doc_view_b
    changes = self.a_changes
    is_a = true
  elseif core.active_view == self.doc_view_b then
    view = self.doc_view_b
    to_view = self.doc_view_a
    changes = self.b_changes
  end

  if not view then return end

  local line = view.doc:get_selection()
  local tag = changes[line].tag
  if tag == "equal" then
    core.error("No valid change selected")
    return
  end

  while changes[line-1] and changes[line-1].tag == tag do
    line = line - 1
  end

  view.doc:set_selection(line, 1, line, 1)

  local _, y = view:get_line_screen_position(line, 1)
  to_view.scroll.to.y =  view.scroll.y
  to_view.scroll.y =  view.scroll.y

  local target_line = to_view:resolve_screen_position(
    to_view.position.x + style.padding.x, y
  )

  self:sync(line, target_line, is_a)
end

function DiffView:on_mouse_pressed(button, x, y, clicks)
  if button == "left" and self.hovered_sync then
    self:sync(
      self.hovered_sync.line,
      self.hovered_sync.target_line,
      self.hovered_sync.is_a
    )
    self.hovered_sync = nil
    return
  end
  if DiffView.super.on_mouse_pressed(self, button, x, y, clicks) then
    self.scroll.y = self.scroll.to.y
    self.doc_view_a.scroll.to.y = self.scroll.y
    self.doc_view_a.scroll.y = self.scroll.y
    self.doc_view_b.scroll.to.y = self.scroll.y
    self.doc_view_b.scroll.y = self.scroll.y
    return true
  elseif self.doc_view_a:on_mouse_pressed(button, x, y, clicks) then
    self.doc_view_a.scroll.y = self.doc_view_a.scroll.to.y
    self.scroll.to.y = self.doc_view_a.scroll.y
    self.scroll.y = self.doc_view_a.scroll.y
    self.doc_view_b.scroll.to.y = self.doc_view_a.scroll.y
    self.doc_view_b.scroll.y = self.doc_view_a.scroll.y
    return true
  elseif self.doc_view_b:on_mouse_pressed(button, x, y, clicks) then
    self.doc_view_b.scroll.y = self.doc_view_b.scroll.to.y
    self.scroll.to.y = self.doc_view_b.scroll.y
    self.scroll.y = self.doc_view_b.scroll.y
    self.doc_view_a.scroll.to.y = self.doc_view_b.scroll.y
    self.doc_view_a.scroll.y = self.doc_view_b.scroll.y
    return true
  end
  for _, view in ipairs({self.doc_view_a, self.doc_view_b}) do
    if
      x >= view.position.x
      and
      x <= view.position.x + view.size.x
    then
      core.set_active_view(view)
      break
    end
  end
end

function DiffView:on_mouse_released(...)
  DiffView.super.on_mouse_released(self, ...)
  self.doc_view_a:on_mouse_released(...)
  self.doc_view_b:on_mouse_released(...)
end

---@param self plugins.diffview.view
local function check_hovered_sync(self, x, y)
  local x1 = self.doc_view_a.position.x + self.doc_view_a.size.x
  local x2 = self.doc_view_b.position.x + style.padding.x / 2

  if x >= x1 and x <= x2 then
    ---@type integer
    local line
    ---@type integer
    local target_line
    ---@type diff.changes[]
    local changes
    ---@type boolean
    local is_a = false

    -- hovering side A
    if x <= x1 + ((x2 - x1) / 2) then
      line = self.doc_view_a:resolve_screen_position(x1 - style.padding.x, y)
      target_line = self.doc_view_b:resolve_screen_position(x2 - style.padding.x, y)
      changes = self.a_changes
      is_a = true

    -- hovering side B
    elseif x >= x1 + ((x2 - x1) / 2) + style.padding.x / 2  then
      line = self.doc_view_b:resolve_screen_position(x2 - style.padding.x, y)
      target_line = self.doc_view_a:resolve_screen_position(x1 - style.padding.x, y)
      changes = self.b_changes
    end

    -- check if hovering valid line and save it
    if line and changes[line] and changes[line].tag ~= "equal" then
      if not changes[line-1] or changes[line-1].tag ~= changes[line].tag then
        self.hovered_sync = {
          is_a = is_a,
          line = line,
          target_line = target_line
        }
        return
      end
    end
  end

  self.hovered_sync = nil
end

function DiffView:on_mouse_moved(...)
  -- ignore config.animate_drag_scroll by setting scroll.y to scroll.to.y
  -- since views would end in different positions, also scrolling two
  -- views at the same time with animation on would be more cpu demanding.

  if DiffView.super.on_mouse_moved(self, ...) then
    if self.v_scrollbar.dragging then
      self.scroll.y = self.scroll.to.y
      self.doc_view_a.scroll.to.y = self.scroll.y
      self.doc_view_a.scroll.y = self.scroll.y
      self.doc_view_b.scroll.to.y = self.scroll.y
      self.doc_view_b.scroll.y = self.scroll.y
      return true
    end
  end
  self.doc_view_a:on_mouse_moved(...)
  if self.doc_view_a:scrollbar_dragging() then
    self.doc_view_a.scroll.y = self.doc_view_a.scroll.to.y
    self.scroll.to.y = self.doc_view_a.scroll.y
    self.scroll.y = self.doc_view_a.scroll.y
    self.doc_view_b.scroll.y = self.doc_view_a.scroll.y
    self.doc_view_b.scroll.to.y = self.doc_view_a.scroll.y
    return true
  end
  self.doc_view_b:on_mouse_moved(...)
  if self.doc_view_b:scrollbar_dragging() then
    self.doc_view_b.scroll.y = self.doc_view_b.scroll.to.y
    self.scroll.to.y = self.doc_view_b.scroll.y
    self.scroll.y = self.doc_view_b.scroll.y
    self.doc_view_a.scroll.y = self.doc_view_b.scroll.y
    self.doc_view_a.scroll.to.y = self.doc_view_b.scroll.y
    return true
  end
  check_hovered_sync(self, ...)
end

function DiffView:on_mouse_left()
  DiffView.super.on_mouse_left(self)
  self.doc_view_a:on_mouse_left()
  self.doc_view_b:on_mouse_left()
end

function DiffView:on_mouse_wheel(y, x)
  if keymap.modkeys["shift"] then
    x = y
    y = 0
  end
  if y and y ~= 0 then
    self.doc_view_a.scroll.to.y = self.doc_view_a.scroll.to.y + y * -config.mouse_wheel_scroll
    self.doc_view_b.scroll.to.y = self.doc_view_b.scroll.to.y + y * -config.mouse_wheel_scroll
  end
  if x and x ~= 0 then
    self.doc_view_a.scroll.to.x = self.doc_view_a.scroll.to.x + x * -config.mouse_wheel_scroll
    self.doc_view_b.scroll.to.x = self.doc_view_b.scroll.to.x + x * -config.mouse_wheel_scroll
  end
end

function DiffView:on_scale_change(...)
  self.v_scrollbar.contracted_size = style.expanded_scrollbar_size  * 2
  self.v_scrollbar.expanded_size = style.expanded_scrollbar_size * 2
  self.doc_view_a:on_scale_change(...)
  self.doc_view_b:on_scale_change(...)
end

function DiffView:on_touch_moved(...)
  DiffView.super.on_touch_moved(self, ...)
  self.doc_view_a:on_touch_moved(...)
  self.doc_view_b:on_touch_moved(...)
end

function DiffView:get_scrollable_size()
  local a_lines, b_lines = #self.doc_view_a.doc.lines, #self.doc_view_b.doc.lines
  local a_gaps = (self.a_gaps[a_lines] and self.a_gaps[a_lines][2] or 0)
  local b_gaps = (self.b_gaps[b_lines] and self.b_gaps[b_lines][2] or 0)
  local lc = math.max(a_lines + a_gaps, b_lines + b_gaps)
  if not config.scroll_past_end then
    local _, _, _, h_scroll = self.h_scrollbar:get_track_rect()
    return self.doc_view_a:get_line_height() * (lc) + style.padding.y * 2 + h_scroll
  end
  return self.doc_view_a:get_line_height() * (lc - 1) + self.size.y
end

---@param parent core.diffview
---@param self core.docview
---@param line integer
---@param x number
---@param y number
---@param changes diff.changes[]
local function draw_line_text_override(parent, self, line, x, y, changes)
  y = y + self:get_line_text_y_offset()
  local h = self:get_line_height()
  local change = changes[line]
  if change and change.tag ~= "equal" then
    local delete_bg = style.diff_delete_background
    local insert_bg = style.diff_insert_background
    local delete_inline = style.diff_delete_inline
    local insert_inline = style.diff_insert_inline
    if config.plugins.diffview.plain_text then
      -- increase opacity to half
      delete_bg = { table.unpack(delete_bg) }
      delete_bg[4] = 128
      insert_bg = { table.unpack(insert_bg) }
      insert_bg[4] = 128
      -- make inline opaque
      delete_inline = style.diff_delete
      insert_inline = style.diff_insert
    end
    if change.tag == "delete" then
      renderer.draw_rect(
        self.position.x, y, self.size.x, h, delete_bg
      )
    elseif change.tag == "insert" then
      renderer.draw_rect(
        self.position.x, y, self.size.x, h, insert_bg
      )
    else
      if change.changes then
        if changes == parent.a_changes then
          renderer.draw_rect(self.position.x, y, self.size.x, h, delete_bg)
        else
          renderer.draw_rect(self.position.x, y, self.size.x, h, insert_bg)
        end
        ---@type diff.changes[]
        local mods = change.changes
        local text = ""
        local deletes = 0
        for i, edit in ipairs(mods) do
          if edit.tag == "insert" then
            text = text .. edit.val
            local tx = self:get_col_x_offset(line, i - deletes)
            local w = self:get_font():get_width(edit.val);
            renderer.draw_rect(
              x + tx, y, w, h,
              changes == parent.a_changes
                and delete_inline
                or insert_inline
            )
          elseif edit.tag == "delete" then
            deletes = deletes + 1
          end
        end
      end
    end
  end
end

function DiffView:patch_views()
  if self.views_patched then return end
  self.views_patched = true

  local parent = self

  ---@param doc_view core.docview
  ---@param is_a boolean
  local function wrap_draw_line_text(doc_view, is_a)
    local orig = doc_view.draw_line_text
    doc_view.draw_line_text = function(self, line, x, y)
      local changes = is_a and parent.a_changes or parent.b_changes
      draw_line_text_override(parent, self, line, x, y, changes)
      local has_changes = changes[line] and changes[line].tag ~= "equal"
      if
        changes[line] and changes[line].tag ~= "equal"
        and
        (not changes[line-1] or changes[line].tag ~= changes[line-1].tag)
      then
        local ax, icon
        local pad = style.padding.x / 2
        if is_a then
          icon = ">"
          ax = self.position.x + self.size.x + pad
        else
          icon = "<"
          ax = self.position.x - pad
        end
        local color = style.text
        if parent.hovered_sync and parent.hovered_sync.is_a == is_a then
          if parent.hovered_sync.line == line then
            color = style.caret
          end
        end
        core.root_view:defer_draw(function()
          core.push_clip_rect(parent.position.x, parent.position.y, parent.size.x, parent.size.y)
          local ay = y + (self:get_line_height() / 2) - (style.icon_font:get_height() / 2)
          renderer.draw_text(style.icon_font, icon, ax, ay, color)
          core.pop_clip_rect()
        end)
      end
      if has_changes and config.plugins.diffview.plain_text then
        renderer.draw_text(
          self:get_font(),
          self.doc.lines[line],
          x, y + self:get_line_text_y_offset(),
          config.plugins.diffview.plain_text_color
        )
        return self:get_line_height()
      else
        return orig(self, line, x, y)
      end
    end
  end

  ---@param doc_view core.docview
  ---@param is_a boolean
  local function wrap_get_line_screen_position(doc_view, is_a)
    doc_view.get_line_screen_position = function(self, line, col)
      local x, y = self:get_content_offset()
      local lh = self:get_line_height()
      local gaps = is_a and parent.a_gaps or parent.b_gaps
      local gap_y = (gaps[line] and gaps[line][2] or 0) * lh
      y = y + (line - 1) * lh + gap_y + style.padding.y
      if col then
        return x + self:get_gutter_width() + self:get_col_x_offset(line, col), y
      else
        return x + self:get_gutter_width(), y
      end
    end
  end

  ---@param doc_view core.docview
  ---@param is_a boolean
  local function wrap_resolve_screen_position(doc_view, is_a)
    doc_view.resolve_screen_position = function(self, x, y)
      local lines = self.doc.lines
      local lh = self:get_line_height()
      local gaps = is_a and parent.a_gaps or parent.b_gaps

      for i = 1, #lines do
        local line_x, line_y = self:get_line_screen_position(i)
        local next_y
        if i < #lines then
          local _
          _, next_y = self:get_line_screen_position(i + 1)
        else
          next_y = line_y + lh + ((gaps[i] and gaps[i][1] or 0) * lh)
        end

        if (y >= line_y or i == 1) and y < next_y then
          local col = self:get_x_offset_col(i, x - line_x)
          return i, col
        end
      end

      local last = #lines
      local line_x, _ = self:get_line_screen_position(last)
      return last, self:get_x_offset_col(last, x - line_x)
    end
  end

  ---@param doc_view core.docview
  ---@param is_a boolean
  local function wrap_get_visible_line_range(doc_view, is_a)
    doc_view.get_visible_line_range = function(self)
      local _, oy, _, y2 = self:get_content_bounds()
      local lh = self:get_line_height()
      local lines = self.doc.lines
      local minline, maxline = 1, #lines
      local gaps = is_a and parent.a_gaps or parent.b_gaps

      local y = style.padding.y
      for i = 1, #lines do
        local gap = (gaps[i] and gaps[i][2] or 0) * lh
        local h = lh
        local total = y + h
        y = total
        if total + gap > oy then
          minline = i
          break
        end
      end

      for i = minline, #lines do
        local gap = (gaps[i] and gaps[i][2] or 0) * lh
        local h = lh
        local total = y + h
        y = total
        if total + gap > y2 then
          maxline = i
          break
        end
      end

      return minline, maxline
    end
  end

  ---@param doc_view core.docview
  ---@param is_a boolean
  local function wrap_get_scrollable_size(doc_view, is_a)
    doc_view.get_scrollable_size = function(self)
      local gaps = is_a and parent.a_gaps or parent.b_gaps
      local lc = #self.doc.lines
      lc = lc + (gaps[lc] and gaps[lc][2] or 0)
      if not config.scroll_past_end then
        local _, _, _, h_scroll = self.h_scrollbar:get_track_rect()
        return self:get_line_height() * (lc) + style.padding.y * 2 + h_scroll
      end
      return self:get_line_height() * (lc - 1) + self.size.y
    end
  end

  ---@param doc_view core.docview
  ---@param is_a boolean
  local function wrap_scroll_to_line(doc_view, is_a)
    local orig = doc_view.scroll_to_line
    doc_view.scroll_to_line = function(self, ...)
      orig(self, ...)
      parent.scroll.y = self.scroll.y
      parent.scroll.to.y = self.scroll.y
      if is_a then
        parent.doc_view_b.scroll.y = self.scroll.y
        parent.doc_view_b.scroll.to.y = self.scroll.y
      else
        parent.doc_view_a.scroll.y = self.scroll.y
        parent.doc_view_a.scroll.to.y = self.scroll.y
      end
    end
  end

  ---@param doc_view core.docview
  local function wrap_draw(doc_view)
    doc_view.draw = function(self)
      self:draw_background(style.background)
      local _, indent_size = self.doc:get_indent_info()
      self:get_font():set_tab_size(indent_size)

      local minline, maxline = self:get_visible_line_range()
      local lh = self:get_line_height()

      local gw, gpad = self:get_gutter_width()
      for i = minline, maxline do
        local _, y = self:get_line_screen_position(i)
        self:draw_line_gutter(i, self.position.x, y, gpad and gw - gpad or gw)
      end

      local pos = self.position
      -- the clip below ensure we don't write on the gutter region. On the
      -- right side it is redundant with the Node's clip.
      core.push_clip_rect(pos.x + gw, pos.y, self.size.x - gw, self.size.y)
      for i = minline, maxline do
        local x, y = self:get_line_screen_position(i)
        y = y + (self:draw_line_body(i, x, y) or lh)
      end
      self:draw_overlay()
      core.pop_clip_rect()

      self:draw_scrollbar()
    end
  end

  ---@param doc_view core.docview
  local function wrap_doc_raw_insert(doc_view)
    local orig = doc_view.doc.raw_insert
    doc_view.doc.raw_insert = function(...)
      parent:update_diff()
      return orig(...)
    end
  end

  ---@param doc_view core.docview
  local function wrap_doc_raw_remove(doc_view)
    local orig = doc_view.doc.raw_remove
    doc_view.doc.raw_remove = function(...)
      parent:update_diff()
      return orig(...)
    end
  end

  ---@param changes diff.changes[]
  local function has_changes(changes)
    for _, change in ipairs(changes) do
      if change.tag ~= "equal" then
        return true
      end
    end
    return false
  end

  ---@param doc_view core.docview
  ---@param is_a boolean
  local function wrap_prev_change(doc_view, is_a)
    doc_view.prev_change = function(self)
      local changes = is_a and parent.a_changes or parent.b_changes
      if not has_changes(changes) then return end

      local line = self.doc:get_selection()
      if not changes[line] then return end
      local tag = changes[line].tag
      if line == 1 then
        line = #self.doc.lines
      else
        line = line - 1
      end

      local target = line
      local in_first_block = tag ~= "equal" and true or false
      local in_second_block = tag == "equal" and true or false

      while true do
        if not changes[target] then break end
        if in_first_block then
          if changes[target].tag ~= tag then
            in_first_block = false
            in_second_block = true
          end
        elseif in_second_block and changes[target].tag ~= "equal" then
          if changes[target-1].tag ~= changes[target].tag then
            break
          end
        end
        target = target - 1
        if target == 1 then
          if changes[target].tag == "equal" then
            target = #self.doc.lines
          else
            break
          end
        elseif target < 1 then
          target = #self.doc.lines
        end
      end

      self.doc:set_selection(target, 1, target, 1)
      self:scroll_to_line(target, false, true)
    end
  end

  ---@param doc_view core.docview
  ---@param is_a boolean
  local function wrap_next_change(doc_view, is_a)
    doc_view.next_change = function(self)
      local changes = is_a and parent.a_changes or parent.b_changes
      if not has_changes(changes) then return end

      local count_lines = #self.doc.lines
      local line = self.doc:get_selection()
      if not changes[line] then return end
      local tag = changes[line].tag
      if line == count_lines then
        line = 1
      else
        line = line + 1
      end

      local target = line
      local in_first_block = tag ~= "equal" and true or false
      local in_second_block = tag == "equal" and true or false

      while true do
        if not changes[target] then break end
        if in_first_block then
          if changes[target].tag ~= tag then
            in_first_block = false
            in_second_block = true
          end
        elseif in_second_block and changes[target].tag ~= "equal" then
          if not changes[target-1] or changes[target-1].tag ~= changes[target].tag then
            break
          end
        end
        target = target + 1
        if target == count_lines then
          if changes[target].tag == "equal" then
            target = 1
          else
            break
          end
        elseif target > count_lines then
          target = 1
        end
      end

      self.doc:set_selection(target, 1, target, 1)
      self:scroll_to_line(target, false, true)
    end
  end

  -- Apply to both views with dynamic referencing
  for _, side in ipairs {
    {view = self.doc_view_a, is_a = true},
    {view = self.doc_view_b, is_a = false}
  } do
    wrap_draw_line_text(side.view, side.is_a)
    wrap_get_line_screen_position(side.view, side.is_a)
    wrap_resolve_screen_position(side.view, side.is_a)
    wrap_get_visible_line_range(side.view, side.is_a)
    wrap_get_scrollable_size(side.view, side.is_a)
    wrap_scroll_to_line(side.view, side.is_a)
    wrap_draw(side.view)
    wrap_doc_raw_insert(side.view)
    wrap_doc_raw_remove(side.view)
    wrap_prev_change(side.view, side.is_a)
    wrap_next_change(side.view, side.is_a)
  end
end

local function redraw_thumb(view_scrollbar)
  local highlight = view_scrollbar.hovering.thumb or view_scrollbar.dragging
  local color = highlight and style.scrollbar2 or style.scrollbar
  color = { table.unpack(color) }
  color[4] = 100
  local x, y, w, h = view_scrollbar:get_thumb_rect()
  renderer.draw_rect(x, y, w, h, color)
end

---Get the scrollbar marker rectangle for a visual row range.
---@param track_y number
---@param track_h number
---@param start_row number
---@param end_row number
---@param scrollable_size number
---@param line_height number
---@param content_padding number
---@return number y
---@return number h
local function get_scrollbar_marker_rect(
  track_y, track_h, start_row, end_row, scrollable_size, line_height,
  content_padding
)
  local scrollable = math.max(1, scrollable_size)
  local start_y = content_padding + start_row * line_height
  local end_y = content_padding + end_row * line_height
  local ratio_start = common.clamp(start_y / scrollable, 0, 1)
  local ratio_end = common.clamp(end_y / scrollable, ratio_start, 1)
  local y = track_y + ratio_start * track_h
  local h = math.max(2 * SCALE, (ratio_end - ratio_start) * track_h)
  return y, h
end

---Get the marker lane that matches the scrollbar selector width.
---@param scrollbar core.scrollbar
---@return number x
---@return number y
---@return number w
---@return number h
local function get_scrollbar_marker_lane(scrollbar)
  local track_x, track_y, track_w, track_h = scrollbar:get_track_rect()
  local thumb_x, _, thumb_w = scrollbar:get_thumb_rect()
  if thumb_w > 0 then
    track_x, track_w = thumb_x, thumb_w
  end
  return track_x, track_y, track_w, track_h
end

function DiffView:draw_scrollbar()
  DiffView.super.draw_scrollbar(self)

  local parent_scrollable = self:get_scrollable_size()
  local parent_lh = self.doc_view_a:get_line_height()
  local parent_x, parent_y, parent_w, parent_h = get_scrollbar_marker_lane(
    self.v_scrollbar
  )

  for _, side in ipairs {
    {
      view = self.doc_view_a,
      changes = self.a_changes,
      gaps = self.a_gaps
    },
    {
      view = self.doc_view_b,
      changes = self.b_changes,
      gaps = self.b_gaps
    },
  } do
    local view = side.view
    local changes = side.changes
    local gaps = side.gaps
    local scrollbar = view.v_scrollbar
    local scrollable = view:get_scrollable_size()
    local lh = view:get_line_height()

    local x, y, w, h = get_scrollbar_marker_lane(scrollbar)

    -- Step 1: group consecutive lines of same change tag
    local change_lines = {}
    for line, change in pairs(changes) do
      change_lines[#change_lines+1] = { line = line, tag = change.tag }
    end
    table.sort(change_lines, function(a, b) return a.line < b.line end)

    local i = 1
    while i <= #change_lines do
      local tag = change_lines[i].tag
      local start_line = change_lines[i].line
      local end_line = start_line

      -- Group consecutive lines with same tag
      while i + 1 <= #change_lines and
            change_lines[i+1].tag == tag and
            change_lines[i+1].line == end_line + 1 do
        i = i + 1
        end_line = change_lines[i].line
      end

      -- Draw block for [start_line, end_line]
      local color =
        tag == "insert" and style.diff_insert
        or tag == "delete" and style.diff_delete
        or tag == "modify" and style.diff_modify

      if color then
        local start_gap = gaps[start_line] and gaps[start_line][2] or 0
        local end_gap = gaps[end_line] and gaps[end_line][2] or start_gap
        local start_row = start_line - 1 + start_gap
        local end_row = end_line + end_gap
        local marker_y, marker_h = get_scrollbar_marker_rect(
          y, h, start_row, end_row, scrollable, lh, style.padding.y
        )

        renderer.draw_rect(x, marker_y, w, marker_h, color)

        local parent_marker_y, parent_marker_h = get_scrollbar_marker_rect(
          parent_y, parent_h, start_row, end_row, parent_scrollable, parent_lh,
          style.padding.y
        )
        renderer.draw_rect(
          parent_x, parent_marker_y, parent_w, parent_marker_h, color
        )
      end

      i = i + 1
    end
  end

  redraw_thumb(self.doc_view_a.v_scrollbar)
  redraw_thumb(self.doc_view_b.v_scrollbar)
  redraw_thumb(self.v_scrollbar)
end

function DiffView:update()
  DiffView.super.update(self)
  local _, _, scroll_w, _ = self.v_scrollbar:_get_track_rect_normal()

  self.doc_view_a.position.x = self.position.x
  self.doc_view_a.position.y = self.position.y
  self.doc_view_a.size.x = (self.size.x / 2) - scroll_w - 20 * SCALE
  self.doc_view_a.size.y = self.size.y

  self.doc_view_b.position.x = (self.position.x + self.size.x / 2) - scroll_w + 20 * SCALE
  self.doc_view_b.position.y = self.position.y
  self.doc_view_b.size.x = (self.size.x / 2) - scroll_w - 20 * SCALE
  self.doc_view_b.size.y = self.size.y

  self.doc_view_a:update()
  self.doc_view_b:update()
end

function DiffView:draw()
  DiffView.super.draw(self)
  self:draw_background(style.background)
  self.doc_view_a:draw()
  self.doc_view_b:draw()
  self:draw_scrollbar()
end


-- Helper functions to start file to file or string to string diff viewer.
local function start_compare()
  if not element_a or not element_b then
    core.log("First select something to compare")
    return
  end
  local view = DiffView(element_a, element_b, DiffView.type.FILE_FILE)
  core.root_view:get_active_node_default():add_view(view)
  core.set_active_view(view)
  element_a = nil
  element_b = nil
end

local function start_compare_string()
  if not element_a_text or not element_b_text then
    core.log("First select something to compare")
    return
  end
  local view = DiffView(element_a_text, element_b_text, DiffView.type.STRING_STRING)
  core.root_view:get_active_node_default():add_view(view)
  core.set_active_view(view)
  element_a_text = nil
  element_b_text = nil
end


-- Register file compare commands
command.add("core.docview", {
  ["diff-view:select-file-for-compare"] = function(dv)
    if dv.doc and dv.doc.abs_filename then
      element_a = dv.doc.abs_filename
    end
  end
})

command.add(
  function()
    return element_a and core.active_view and core.active_view:is(DocView),
    core.active_view
  end, {
  ["diff-view:compare-file-with-selected"] = function(dv)
    if dv.doc and dv.doc.abs_filename then
      element_b = dv.doc.abs_filename
    end
    start_compare()
  end
})

command.add(nil, {
  ["diff-view:start-files-comparison"] = function()
    command.perform("core:open-file", "Select File A", function(file_a)
      element_a = file_a
      command.perform("core:open-file", "Select File B", function(file_b)
        element_b = file_b
        start_compare()
      end)
    end)
  end
})

command.add(nil, {
  ["diff-view:start-strings-comparison"] = function()
    element_a_text = ""
    element_b_text = ""
    start_compare_string()
  end
})


-- Register changes navigation and sync commands
command.add(
  function()
    return core.active_view
        and core.active_view:is(DocView)
        and core.active_view.diff_view_parent,
      core.active_view
  end, {
  ["diff-view:prev-change"] = function(dv)
    dv:prev_change()
  end,

  ["diff-view:next-change"] = function(dv)
    dv:next_change()
  end,

  ["diff-view:sync-change"] = function(dv)
    dv.diff_view_parent:sync_selected()
  end
})

keymap.add({
  ["ctrl+alt+,"] = "diff-view:prev-change",
  ["ctrl+alt+."] = "diff-view:next-change",
  ["ctrl+return"] = "diff-view:sync-change",
})


-- Register text compare commands
local function text_select_compare_predicate()
  local is_docview = core.active_view
    and core.active_view:is(DocView)
    and core.active_view.doc
  local has_selection = is_docview and core.active_view.doc:has_any_selection()
  return has_selection, has_selection and core.active_view.doc
end

local function text_compare_with_predicate()
  local is_docview = (element_a_text and core.active_view)
    and (core.active_view:is(DocView) and core.active_view.doc)
  local has_selection = is_docview and core.active_view.doc:has_any_selection()
  return has_selection, has_selection and core.active_view.doc
end

command.add(text_select_compare_predicate, {
  ["diff-view:select-text-for-compare"] = function(doc)
    element_a_text = doc:get_selection_text()
  end
})

command.add(text_compare_with_predicate, {
  ["diff-view:compare-text-with-selected"] = function(doc)
    element_b_text = doc:get_selection_text()
    start_compare_string()
  end
})


-- Register context menu items
core.add_thread(function()
  if config.plugins.cotextmenu then
    local contextmenu = require "plugins.contextmenu"

    contextmenu:register(text_select_compare_predicate, {
      contextmenu.DIVIDER,
      {
        text = "Select Text for Compare",
        command = "diff-view:select-text-for-compare"
      }
    })

    contextmenu:register(text_compare_with_predicate, {
      {
        text = "Compare Text with Selected",
        command = "diff-view:compare-text-with-selected"
      }
    })
  end
end)


-- Register treeview context menu items
core.add_thread(function()
  if not config.plugins.treeview then return end

  ---@module 'plugins.treeview'
  local TreeView = require "plugins.treeview"
  ---@module 'core.contextmenu'
  local TreeViewMenu = TreeView.contextmenu

  TreeViewMenu:register(
    function()
      return TreeView.hovered_item
        and system.get_file_info(TreeView.hovered_item.abs_filename).type == "file"
    end,
    {
      TreeViewMenu.DIVIDER,
      { text = "Select for Compare", command = "treeview:select-for-compare" }
    }
  )

  TreeViewMenu:register(
    function()
      return element_a and TreeView.hovered_item
        and system.get_file_info(TreeView.hovered_item.abs_filename).type == "file"
    end,
    {
      { text = "Compare with Selected", command = "treeview:compare-with-selected" }
    }
  )

  command.add(
    function()
      if
        TreeView.hovered_item
        and system.get_file_info(
          TreeView.hovered_item.abs_filename
        ).type == "file"
      then
        return true, TreeView.hovered_item.abs_filename
      end
      return false
    end, {
    ["treeview:select-for-compare"] = function(file)
      element_a = file
    end
  })

  command.add(
    function()
      if
        element_a and TreeView.hovered_item
        and system.get_file_info(
          TreeView.hovered_item.abs_filename
        ).type == "file"
      then
        return true, TreeView.hovered_item.abs_filename
      end
      return false
    end, {
    ["treeview:compare-with-selected"] = function(file)
      element_b = file
      start_compare()
    end
  })

end)


---Functionality to view the textual differences of two elements.
---@class plugins.diffview
local diffview = {
  ---The differences viewer exposed for extensiblity.
  ---@type plugins.diffview.view
  Viewer = DiffView
}

---Helper differences view to rootview add.
---@param view plugins.diffview.view
local function compare_add_to_root_node(view)
  core.root_view:get_active_node_default():add_view(view)
  core.set_active_view(view)
end

---Helper differences starter.
---@param a string
---@param b string
---@param ct? plugins.diffview.view.type
---@param names? plugins.diffview.view.string_names
---@param noshow? boolean
---@return plugins.diffview.view
local function compare_start(a, b, ct, names, noshow)
  local view = DiffView(a, b, ct, names)
  if not noshow then
    compare_add_to_root_node(view)
  end
  return view
end

---Create a file to file diff viewer.
---@param a string
---@param b string
---@param noshow? boolean If true doesn't adds to the rootview
---@return plugins.diffview.view
function diffview.file_to_file(a, b, noshow)
  return compare_start(a, b, DiffView.type.FILE_FILE, nil, noshow)
end

---Create a string to string diff viewer.
---@param a string
---@param b string
---@param a_name? string
---@param b_name? string
---@param noshow? boolean If true doesn't adds to the rootview
---@return plugins.diffview.view
function diffview.string_to_string(a, b, a_name, b_name, noshow)
  return compare_start(
    a, b, DiffView.type.STRING_STRING, {a = a_name, b = b_name}, noshow
  )
end

---Create a file to string diff viewer.
---@param a string
---@param b string
---@param b_name? string
---@param noshow? boolean If true doesn't adds to the rootview
---@return plugins.diffview.view
function diffview.file_to_string(a, b, b_name, noshow)
  return compare_start(a, b, DiffView.type.FILE_STRING, {b = b_name}, noshow)
end

---Create a string to file diff viewer.
---@param a string
---@param b string
---@param a_name? string
---@param noshow? boolean If true doesn't adds to the rootview
---@return plugins.diffview.view
function diffview.string_to_file(a, b, a_name, noshow)
  return compare_start(a, b, DiffView.type.STRING_FILE, {a = a_name}, noshow)
end


return diffview
