local core = require "core"
local common = require "core.common"
local config = require "core.config"
local style = require "core.style"
local keymap = require "core.keymap"
local translate = require "core.doc.translate"
local ime = require "core.ime"
local View = require "core.view"

local CACHE_LINE_LEN = 500
local LONG_LINE_EXTRA_PIXELS = 2000

local IME_VIEW = nil
local IME_STATE = {line1 = 0, col1 = 0, line2 = 0, col2 = 0, w = 0, h = 0}

---@class core.docview.position
---@field line integer
---@field col integer
---@field offset number

---@class core.docview.ime_selection
---@field from integer
---@field size integer

---View for editing documents with syntax highlighting and text editing.
---Extends View to provide text editing capabilities including selection,
---scrolling, IME support, and rendering with syntax highlighting.
---@class core.docview : core.view
---@overload fun(doc: core.doc):core.docview
---@field super core.view
---@field doc core.doc
---@field font string
---@field last_x_offset core.docview.position
---@field ime_selection core.docview.ime_selection
---@field ime_status boolean
---@field hovering_gutter boolean
---@field cache_font renderer.font
---@field cache_font_size number
---@field cache_indent_size integer
---@field mouse_selecting table?
---@field last_line1 integer
---@field last_col1 integer
---@field last_line2 integer
---@field last_col2 integer
local DocView = View:extend()

function DocView:__tostring() return "DocView" end

DocView.context = "session"

local function utf8_previous_start(text, col)
  col = common.clamp(col, 1, #text)
  while col > 1 and common.is_utf8_cont(text, col) do
    col = col - 1
  end
  return col
end

local function utf8_next_start(text, col)
  if col >= #text then return #text end
  col = utf8_previous_start(text, col)
  return utf8extra.next(text, col) or #text
end

local function utf8_char_end(text, col)
  col = utf8_previous_start(text, col)
  local next_col = utf8extra.next(text, col)
  return next_col and (next_col - 1) or #text
end

local function trim_token_to_col(text, col, max_col)
  local length = max_col - col + 1
  if length >= #text then return text end
  return text:sub(1, length)
end

---Resolve a visual row and horizontal target to a document position.
---@param dv core.docview
---@param row integer Visual row
---@param x number Horizontal offset from text origin
---@return integer line New line number
---@return integer col New column number
local function position_from_visual_row_x(dv, row, x)
  row = common.clamp(row, 1, dv:visual_line_count())
  local line = dv:visual_position_from_row(row)
  local col = dv:get_visual_line_col_from_x(row, x)
  return line, col
end

---Helper to move cursor vertically while preserving horizontal offset.
---@param dv core.docview
---@param line integer Current line
---@param col integer Current column
---@param offset integer Visual row offset (-1 for up, 1 for down)
---@return integer line New line number
---@return integer col New column number
local function move_to_line_offset(dv, line, col, offset)
  local xo = dv.last_x_offset
  if xo.line ~= line or xo.col ~= col then
    xo.offset = dv:get_col_x_offset(line, col)
  end
  local row = dv:visual_row_from_position(line, col)
  xo.line, xo.col = position_from_visual_row_x(dv, row + offset, xo.offset)
  return xo.line, xo.col
end


DocView.translate = {
  ["previous_page"] = function(doc, line, col, dv)
    local min, max = dv:get_visible_line_range()
    local row = dv:visual_row_from_position(line, col)
    local target_row = math.max(1, row - (max - min))
    return dv:visual_position_from_row(target_row), 1
  end,

  ["next_page"] = function(doc, line, col, dv)
    local min, max = dv:get_visible_line_range()
    local row = dv:visual_row_from_position(line, col)
    local target_row = math.min(dv:visual_line_count(), row + (max - min))
    local target_line = dv:visual_position_from_row(target_row)
    if target_line == #doc.lines then
      return #doc.lines, #doc.lines[target_line]
    end
    return target_line, 1
  end,

  ["previous_line"] = function(doc, line, col, dv)
    if dv:visual_row_from_position(line, col) == 1 then
      return line, 1
    end
    return move_to_line_offset(dv, line, col, -1)
  end,

  ["next_line"] = function(doc, line, col, dv)
    if dv:visual_row_from_position(line, col) == dv:visual_line_count() then
      return line, math.huge
    end
    return move_to_line_offset(dv, line, col, 1)
  end,
}


---Constructor - initializes a document view.
---@param doc core.doc Document to display
function DocView:new(doc)
  DocView.super.new(self)
  self.cursor = "ibeam"
  self.scrollable = true
  self.doc = assert(doc)
  self.doc.cache.col_x = {}
  self.doc.cache.ulen = {}
  self.font = "code_font"
  self.last_x_offset = {}
  self.ime_selection = { from = 0, size = 0 }
  self.ime_status = false
  self.hovering_gutter = false
  self.v_scrollbar:set_forced_status(config.force_scrollbar_status)
  self.h_scrollbar:set_forced_status(config.force_scrollbar_status)
  self.cache_font = self:get_font()
  self.cache_font_size = self.cache_font:get_size()
  local _, indent_size = self.doc:get_indent_info()
  self.cache_indent_size = indent_size
  self.visual_lines_dirty = true
  self.visual_lines = nil
end


function DocView:get_state()
  return {
    filename = self.doc.filename,
    selection = { self.doc:get_selection() },
    scroll = { x = self.scroll.to.x, y = self.scroll.to.y },
    crlf = self.doc.crlf,
    text = self.doc.new_file and self.doc:get_text(1, 1, math.huge, math.huge)
  }
end


function DocView.from_state(state)
  local dv
  if not state.filename then
    -- document not associated to a file
    dv = DocView(core.open_doc())
  else
    -- we have a filename, try to read the file
    local ok, doc = pcall(core.open_doc, state.filename)
    if ok then
      dv = DocView(doc)
    end
  end
  if dv and dv.doc then
    if dv.doc.new_file and state.text then
      dv.doc:insert(1, 1, state.text)
      dv.doc.crlf = state.crlf
    end
    dv.doc:set_selection(table.unpack(state.selection))
    dv.last_line1, dv.last_col1, dv.last_line2, dv.last_col2 = dv.doc:get_selection()
    dv.scroll.x, dv.scroll.to.x = state.scroll.x, state.scroll.x
    dv.scroll.y, dv.scroll.to.y = state.scroll.y, state.scroll.y
  end
  return dv
end


---Attempt to close the view, prompting to save if document is dirty.
---Shows "Unsaved Changes" dialog if this is the last view of a dirty document.
---@param do_close function Callback to execute when close is confirmed
function DocView:try_close(do_close)
  if self.doc:is_dirty()
  and #core.get_views_referencing_doc(self.doc) == 1 then
    core.command_view:enter("Unsaved Changes; Confirm Close", {
      submit = function(_, item)
        if item.text:match("^[cC]") then
          do_close()
        elseif item.text:match("^[sS]") then
          self.doc:save()
          do_close()
        end
      end,
      suggest = function(text)
        local items = {}
        if not text:find("^[^cC]") then table.insert(items, "Close Without Saving") end
        if not text:find("^[^sS]") then table.insert(items, "Save And Close") end
        return items
      end
    })
  else
    do_close()
  end
end


---Get the display name for the tab (filename with * if dirty).
---@return string name Document name with asterisk if modified
function DocView:get_name()
  local post = self.doc:is_dirty() and "*" or ""
  local name = self.doc:get_name()
  return name:match("[^/%\\]*$") .. post
end


---Get the full filename path for display (with home directory encoded).
---@return string filename Full path or name with asterisk if modified
function DocView:get_filename()
  if self.doc.abs_filename then
    local post = self.doc:is_dirty() and "*" or ""
    return common.home_encode(self.doc.abs_filename) .. post
  end
  return self:get_name()
end


---Get the total scrollable height of the document.
---@return number height Total height in pixels
function DocView:get_scrollable_size()
  local row_count = self:visual_line_count()
  if not config.scroll_past_end then
    local _, _, _, h_scroll = self.h_scrollbar:get_track_rect()
    return self:get_line_height() * row_count + style.padding.y * 2 + h_scroll
  end
  return self:get_line_height() * (row_count - 1) + self.size.y
end


---Get the scrollable width (infinite for horizontal scrolling).
---@return number width Always returns math.huge
function DocView:get_h_scrollable_size()
  return math.huge
end


---Get the font used for rendering text.
---@return renderer.font font The code font
function DocView:get_font()
  return style[self.font]
end


---Get the line height in pixels.
---@return integer height Line height including line spacing
function DocView:get_line_height()
  return math.floor(self:get_font():get_height() * config.line_height)
end


---Get the gutter width (line numbers area).
---@return number width Total gutter width
---@return number padding Padding within gutter
function DocView:get_gutter_width()
  local padding = style.padding.x * 2
  if config.show_line_numbers then
    return self:get_font():get_width(#self.doc.lines) + padding, padding
  end
  return style.padding.x, padding
end


---Get the screen position of a line (and optionally column).
---@param line integer Line number
---@param col? integer Optional column number
---@return number x Screen x coordinate
---@return number y Screen y coordinate
function DocView:get_line_screen_position(line, col)
  local x, y = self:get_content_offset()
  local lh = self:get_line_height()
  local gw = self:get_gutter_width()
  y = y + (self:visual_row_from_position(line, col) - 1) * lh + style.padding.y
  if col then
    return x + gw + self:get_col_x_offset(line, col), y
  else
    return x + gw, y
  end
end


---Mark the visual-line model dirty.
---@param from_line? integer First line that changed (reserved for incremental rebuilds)
function DocView:invalidate_visual_lines(from_line)
  self.visual_lines_dirty = true
  if not self.visual_lines_invalid_from or not from_line then
    self.visual_lines_invalid_from = from_line
  else
    self.visual_lines_invalid_from = math.min(self.visual_lines_invalid_from, from_line)
  end
  if self.doc.get_change_id then
    self.visual_lines_change_id = self.doc:get_change_id()
  end
end


---Return plugin-provided hidden document lines.
---@return table<integer, boolean>? hidden_lines
function DocView:get_hidden_lines()
  return nil
end


---Return visual row start columns for a document line.
---@param line integer
---@return integer[]? starts
function DocView:get_line_wraps(line)
  return nil
end


---Return whether this view needs a materialized visual-line model.
---Views with wraps or other row expansions should override this.
---@return boolean
function DocView:has_variable_visual_lines()
  return self.get_line_wraps ~= DocView.get_line_wraps
end


---Rebuild the composed visual-line model.
---@param from_line? integer First document line that may have changed
function DocView:rebuild_visual_lines(from_line)
  local hidden = self:get_hidden_lines() or {}
  if not next(hidden) and not self:has_variable_visual_lines() then
    self.visual_lines = {
      identity = true,
      line_count = math.max(1, #self.doc.lines),
      hidden_lines = hidden
    }
    self.visual_lines_dirty = false
    self.visual_lines_invalid_from = nil

    if self.on_visual_lines_rebuilt then
      self:on_visual_lines_rebuilt()
    end
    return
  end

  local previous = self.visual_lines
  local rows, line_to_first_row, line_row_count

  if previous and from_line and from_line > 1 then
    local copy_until = from_line - 1
    local row_limit
    if previous.identity then
      row_limit = math.min(copy_until, previous.line_count)
    else
      row_limit = previous.line_to_first_row[copy_until]
      if row_limit then
        row_limit = row_limit + (previous.line_row_count[copy_until] or 1) - 1
      else
        row_limit = 0
        for line = copy_until, 1, -1 do
          local first = previous.line_to_first_row[line]
          if first then
            row_limit = first + (previous.line_row_count[line] or 1) - 1
            break
          end
        end
      end
    end

    rows = {}
    line_to_first_row = {}
    line_row_count = {}
    for row = 1, row_limit do
      if previous.identity then
        rows[row] = { line = row, col = 1 }
      else
        rows[row] = previous.rows[row]
      end
    end
    for line = 1, copy_until do
      if previous.identity then
        line_to_first_row[line] = line
        line_row_count[line] = 1
      else
        line_to_first_row[line] = previous.line_to_first_row[line]
        line_row_count[line] = previous.line_row_count[line]
      end
    end
  else
    from_line = 1
    rows = {}
    line_to_first_row = {}
    line_row_count = {}
  end

  for line = from_line, #self.doc.lines do
    if not hidden[line] then
      local starts = self:get_line_wraps(line)
      if not starts or #starts == 0 then
        starts = { 1 }
      elseif starts[1] ~= 1 then
        local copy = { 1 }
        for i = 1, #starts do
          copy[#copy + 1] = starts[i]
        end
        starts = copy
      end

      line_to_first_row[line] = #rows + 1
      line_row_count[line] = #starts
      for i = 1, #starts do
        rows[#rows + 1] = { line = line, col = starts[i] }
      end
    end
  end

  if #rows == 0 then
    rows[1] = { line = 1, col = 1 }
    line_to_first_row[1] = 1
    line_row_count[1] = 1
  end

  self.visual_lines = {
    rows = rows,
    line_to_first_row = line_to_first_row,
    line_row_count = line_row_count,
    hidden_lines = hidden
  }
  self.visual_lines_dirty = false
  self.visual_lines_invalid_from = nil

  if self.on_visual_lines_rebuilt then
    self:on_visual_lines_rebuilt()
  end
end


function DocView:get_visual_lines()
  if self.visual_lines_dirty or not self.visual_lines then
    self:rebuild_visual_lines(self.visual_lines_invalid_from)
  end
  return self.visual_lines
end


---Get the total number of visual rows.
---@return integer count
function DocView:visual_line_count()
  local model = self:get_visual_lines()
  return model.identity and model.line_count or #model.rows
end


---Convert a visual row to a real document position.
---@param row integer
---@return integer line
---@return integer col
function DocView:visual_position_from_row(row)
  local model = self:get_visual_lines()
  if model.identity then
    row = common.clamp(row, 1, model.line_count)
    return row, 1
  end
  local rows = model.rows
  row = common.clamp(row, 1, #rows)
  local item = rows[row]
  return item.line, item.col
end


---Convert a real document position to a visual row.
---@param line integer
---@param col? integer
---@return integer row
function DocView:visual_row_from_position(line, col)
  local model = self:get_visual_lines()
  if model.identity then
    if line > #self.doc.lines then
      return model.line_count + 1
    end
    return common.clamp(line, 1, model.line_count)
  end
  if line > #self.doc.lines then
    return #model.rows + 1
  end
  local first = model.line_to_first_row[line]
  if not first then
    while line > 1 and not model.line_to_first_row[line] do
      line = line - 1
    end
    first = model.line_to_first_row[line] or 1
  end

  if not col then
    return first
  end

  local row_count = model.line_row_count[line] or 1
  local best = first
  for row = first + 1, first + row_count - 1 do
    if col >= model.rows[row].col then
      best = row
    else
      break
    end
  end
  return best
end


---Get the first visual row and row count for a real document line.
---@param line integer
---@return integer? first_row
---@return integer row_count
function DocView:visual_rows_for_line(line)
  local model = self:get_visual_lines()
  if model.identity then
    if line < 1 or line > #self.doc.lines then
      return nil, 0
    end
    return line, 1
  end
  return model.line_to_first_row[line], model.line_row_count[line] or 0
end


---Return whether a document line contributes visual rows.
---@param line integer
---@return boolean
function DocView:is_line_visible(line)
  local model = self:get_visual_lines()
  if model.identity then
    return line >= 1 and line <= #self.doc.lines
  end
  return model.line_to_first_row[line] ~= nil
end


---Convert a visual line offset (from get_visible_line_range) to a real
---document position. Plugins that iterate visible rows should call this to get
---the real line and column represented by each composed visual row.
---@param offset integer Visual line index (1-based)
---@return integer line Real document line
---@return integer col  Column where the visual row starts
function DocView:position_from_offset(offset)
  return self:visual_position_from_row(offset)
end


---Convert a real document position to its composed visual line offset.
---@param line integer Real document line
---@param col? integer Optional column
---@return integer offset Visual line index (1-based)
function DocView:offset_from_position(line, col)
  return self:visual_row_from_position(line, col)
end


---Iterate visible rows as virtual offset plus real document position.
---@return fun():integer?, integer?, integer?
function DocView:each_visible_line()
  local offset, max_offset = self:get_visible_line_range()
  offset = offset - 1
  return function()
    while offset < max_offset do
      offset = offset + 1
      local line, col = self:position_from_offset(offset)
      if line then
        return offset, line, col
      end
    end
  end
end


---Get the visual height occupied by a real document line.
---Wrapped lines may span more than one row.
---@param line integer Real document line
---@return number height
function DocView:get_line_visual_height(line)
  local _, count = self:visual_rows_for_line(line)
  return math.max(1, count) * self:get_line_height()
end


---Get the vertical offset for centering text within a line.
---@return number offset Y offset to center text in line height
function DocView:get_line_text_y_offset()
  local lh = self:get_line_height()
  local th = self:get_font():get_height()
  return (lh - th) / 2
end


---Get an estimated range of visible columns. It is an estimate because fonts
---and their fallbacks may not be monospaced or may differ in size. This
---function provides a way of optimization on really long lines for plugins
---that perform drawing operations on them.
---
---It is good practice to set the `extra_cols` parameter to a value that leaves
---room for the differences in font sizes.
---@param line integer
---@param extra_cols? integer Amount of columns to deduce on col1 and include on col2 (default: 100)
---@return integer col1
---@return integer col2
---@return integer ucol1
---@return integer ucol2
function DocView:get_visible_cols_range(line, extra_cols)
  extra_cols = extra_cols or 100

  local text = self.doc:get_utf8_line(line)
  local line_len = #text
  if line_len == 1 then return 1, 1, 1, 1 end

  local gw = self:get_gutter_width()
  local line_x = self.position.x + gw
  local x = -self.scroll.x + self.position.x + gw
  local char_width = self:get_font():get_width("W")
  local non_visible_x = common.clamp(line_x - x, 0, math.huge)

  local non_visible_chars_left = math.floor(non_visible_x / char_width)
  local visible_chars_right = math.floor((self.size.x - gw) / char_width)

  if non_visible_chars_left > line_len then return 0, 0, 0, 0 end

  local col1 = math.max(1, non_visible_chars_left - extra_cols)
  local col2 = math.min(line_len, non_visible_chars_left + (visible_chars_right*2) + extra_cols)
  local ucol1, ucol2 = col1, col2

  -- if line shorter than estimate then handle utf8 stuff
  local cache = self.doc.cache.ulen
  local ulen = cache[line]
  if not ulen then
    ulen = text:ulen(nil, nil, true)
    cache[line] = ulen
  end
  if ulen < line_len then
    ucol1 = text:ulen(1, col1, true)
    ucol2 = text:ulen(1, col2, true)
    col1 = text:ucharpos(ucol1)
    col2 = text:ucharpos(ucol2)
  end

  return col1, col2, ucol1, ucol2
end


---Get the range of visible lines in the current viewport.
---@return integer minline First visible line
---@return integer maxline Last visible line
function DocView:get_visible_line_range()
  local x, y, x2, y2 = self:get_content_bounds()
  local lh = self:get_line_height()
  local minline = math.max(1, math.floor((y - style.padding.y) / lh) + 1)
  local maxline = math.min(self:visual_line_count(), math.floor((y2 - style.padding.y) / lh) + 1)
  return minline, maxline
end


---Get the horizontal pixel offset for a column position.
---Accounts for tabs, syntax highlighting fonts, and caches long lines.
---@param line integer Line number
---@param col integer Column number (byte offset)
---@return number offset Horizontal pixel offset
function DocView:get_col_x_offset(line, col)
  local column = 1
  local xoffset = 0
  local cache = self.doc.cache.col_x
  local line_len = #self.doc:get_utf8_line(line)
  if line_len > CACHE_LINE_LEN then
    if cache[line] and cache[line][col] then
      return cache[line][col]
    elseif not cache[line] then
      cache[line] = {}
    elseif col > 1 then
      for i=col-1, 1, -1 do
        if cache[line][i] then
          column = i
          xoffset = cache[line][i]
          break
        end
      end
    end
  end
  local default_font = self:get_font()
  local _, indent_size = self.doc:get_indent_info()
  default_font:set_tab_size(indent_size)
  local scol = column > 1 and column or nil
  for _, type, text in self.doc.highlighter:each_token(line, scol) do
    local font = style.syntax_fonts[type] or default_font
    if font ~= default_font then font:set_tab_size(indent_size) end
    local length = #text
    if column + length <= col then
      xoffset = xoffset + font:get_width(text, {tab_offset = xoffset})
      column = column + length
      if line_len > CACHE_LINE_LEN and cache[line] then
        cache[line][column] = xoffset
      end
      if column >= col then
        return xoffset
      end
    else
      if line_len > CACHE_LINE_LEN then
        local chunk = text:sub(1, col - column)
        xoffset = xoffset + font:get_width(chunk, {tab_offset = xoffset})
        column = col
        if cache[line] then
          cache[line][column] = xoffset
        end
        return xoffset
      end
      for char in common.utf8_chars(text) do
        if column >= col then
          return xoffset
        end
        xoffset = xoffset + font:get_width(char, {tab_offset = xoffset})
        column = column + #char
        if line_len > CACHE_LINE_LEN and cache[line] then
          cache[line][column] = xoffset
        end
      end
    end
  end
  if line_len > CACHE_LINE_LEN and cache[line] then
    cache[line][column] = xoffset
  end
  return xoffset
end


---Get the column at a horizontal pixel offset.
---Inverse of get_col_x_offset. Accounts for variable-width fonts.
---@param line integer Line number
---@param x number Horizontal pixel offset
---@return integer col Column number (byte offset)
function DocView:get_x_offset_col(line, x)
  if x <= 0 then return 1 end

  local line_text = self.doc:get_utf8_line(line)
  local line_len = #line_text

  -- we leverage the caching already present on col_x, this works on all lines,
  -- but for the moment lets do it only on the cached lines and keep original
  -- code logic intact
  if line_len > CACHE_LINE_LEN then
    local low = 1
    local high = utf8_previous_start(line_text, line_len)

    while low < high do
      local mid = utf8_previous_start(line_text, math.floor((low + high) / 2))
      if mid < low then mid = low end

      if self:get_col_x_offset(line, mid) < x then
        local next_col = utf8_next_start(line_text, mid)
        if next_col <= low then break end
        low = next_col
      else
        high = mid
      end
    end

    if low <= 1 then return 1 end

    local prev_col = utf8_previous_start(line_text, low - 1)
    local prev_x = self:get_col_x_offset(line, prev_col)
    local next_x = self:get_col_x_offset(line, low)
    if x - prev_x < next_x - x then
      return prev_col
    end
    return low
  end

  local xoffset, i = 0, 1
  local default_font = self:get_font()
  local _, indent_size = self.doc:get_indent_info()
  default_font:set_tab_size(indent_size)
  for _, type, text in self.doc.highlighter:each_token(line) do
    local font = style.syntax_fonts[type] or default_font
    if font ~= default_font then font:set_tab_size(indent_size) end
    local width = font:get_width(text, {tab_offset = xoffset})
    -- Don't take the shortcut if the width matches x,
    -- because we need last_i which should be calculated using utf-8.
    if xoffset + width < x then
      xoffset = xoffset + width
      i = i + #text
    else
      for char in common.utf8_chars(text) do
        local w = font:get_width(char, {tab_offset = xoffset})
        if xoffset + w >= x then
          return (x <= xoffset + (w / 2)) and i or i + #char
        end
        xoffset = xoffset + w
        i = i + #char
      end
    end
  end

  return line_len
end


---Resolve a visual row-local horizontal offset to a document column.
---@param row integer Visual row
---@param x number Horizontal offset from text origin
---@return integer col
function DocView:get_visual_line_col_from_x(row, x)
  local line = self:visual_position_from_row(row)
  return self:get_x_offset_col(line, x)
end


---Ensure a document line is visible in this view.
---Plugins that hide lines can override this to reveal the requested line.
---@param line integer
---@return integer line
function DocView:ensure_line_visible(line)
  return line
end


---Convert screen coordinates to document line/column.
---@param x number Screen x coordinate
---@param y number Screen y coordinate
---@return integer line Line number
---@return integer col Column number
function DocView:resolve_screen_position(x, y)
  local ox, oy = self:get_content_offset()
  local gw = self:get_gutter_width()
  local line_y = oy + style.padding.y
  local row = math.floor((y - line_y) / self:get_line_height()) + 1
  local line = self:visual_position_from_row(row)
  local col = self:get_visual_line_col_from_x(row, x - ox - gw)
  return line, col
end


---Scroll to center a line in the viewport.
---@param line integer Line number to scroll to
---@param ignore_if_visible? boolean Don't scroll if line already visible
---@param instant? boolean Jump immediately without animation
function DocView:scroll_to_line(line, ignore_if_visible, instant)
  local min, max = self:get_visible_line_range()
  local row = self:visual_row_from_position(line, 1)
  if not (ignore_if_visible and row > min and row < max) then
    local x, y = self:get_line_screen_position(line)
    local ox, oy = self:get_content_offset()
    local _, _, _, scroll_h = self.h_scrollbar:get_track_rect()
    self.scroll.to.y = math.max(0, y - oy - (self.size.y - scroll_h) / 2)
    if instant then
      self.scroll.y = self.scroll.to.y
    end
  end
end


---Check if this view accepts text input.
---@return boolean accepts Always returns true for DocView
function DocView:supports_text_input()
  return true
end


---Scroll to make a position visible with context padding.
---Ensures the position is visible with surrounding context lines.
---@param line integer Line number
---@param col integer Column number
---@param instant? boolean Jump immediately without animation
function DocView:scroll_to_make_visible(line, col, instant)
  local _, oy = self:get_content_offset()
  local _, ly = self:get_line_screen_position(line, col)
  local lh = self:get_line_height()
  local _, _, _, scroll_h = self.h_scrollbar:get_track_rect()

  local minline, maxline = self:get_visible_line_range()
  local visible_lines = maxline - minline

  local requested_pad = config.scroll_context_lines
  local max_pad = math.floor(visible_lines / 2)
  local pad = not self.mouse_selecting and math.min(requested_pad, max_pad) or 1
  local above = math.max(0, ly - oy - lh * pad)
  local below = ly - oy - self.size.y + scroll_h + lh * (pad + 1)

  self.scroll.to.y = common.clamp(self.scroll.to.y, below, above)

  local gw = self:get_gutter_width()
  local xoffset = self:get_col_x_offset(line, col)
  local xmargin = 3 * self:get_font():get_width(' ')
  local xsup = xoffset + gw + xmargin
  local xinf = xoffset - xmargin
  local _, _, scroll_w = self.v_scrollbar:get_track_rect()
  local size_x = math.max(0, self.size.x - scroll_w)

  if xsup > self.scroll.x + size_x then
    self.scroll.to.x = xsup - size_x
  elseif xinf < self.scroll.x then
    self.scroll.to.x = math.max(0, xinf)
  end

  if instant then
    self.scroll.y = self.scroll.to.y
    self.scroll.x = self.scroll.to.x
  end
end


---Handle mouse movement for cursor changes and text selection.
---Updates cursor icon, gutter hover state, and extends selection if dragging.
---@param x number Screen x coordinate
---@param y number Screen y coordinate
function DocView:on_mouse_moved(x, y, ...)
  DocView.super.on_mouse_moved(self, x, y, ...)

  self.hovering_gutter = false
  local gw = self:get_gutter_width()

  if self:scrollbar_hovering() or self:scrollbar_dragging() then
    self.cursor = "arrow"
  elseif gw > 0 and x >= self.position.x and x <= (self.position.x + gw) then
    self.cursor = "arrow"
    self.hovering_gutter = true
  else
    self.cursor = "ibeam"
  end

  if self.mouse_selecting then
    local l1, c1 = self:resolve_screen_position(x, y)
    local l2, c2, snap_type = table.unpack(self.mouse_selecting)
    if keymap.modkeys["ctrl"] then
      if l1 > l2 then l1, l2 = l2, l1 end
      self.doc.selections = { }
      for i = l1, l2 do
        self.doc:set_selections(i - l1 + 1, i, math.min(c1, #self.doc.lines[i]), i, math.min(c2, #self.doc.lines[i]))
      end
    else
      if snap_type then
        l1, c1, l2, c2 = self:mouse_selection(self.doc, snap_type, l1, c1, l2, c2)
      end
      self.doc:set_selection(l1, c1, l2, c2)
    end
  end
end


---Handle mouse leaving the view area.
---Clears hover state that would otherwise remain until the next mouse move
---inside the DocView.
function DocView:on_mouse_left()
  DocView.super.on_mouse_left(self)
  if self.hovering_gutter then
    self.hovering_gutter = false
    core.redraw = true
  end
end


---Adjust selection based on snap type (word, line).
---@param doc core.doc Document
---@param snap_type string Snap type: "word" or "lines"
---@param line1 integer Start line
---@param col1 integer Start column
---@param line2 integer End line
---@param col2 integer End column
---@return integer line1 Adjusted start line
---@return integer col1 Adjusted start column
---@return integer line2 Adjusted end line
---@return integer col2 Adjusted end column
function DocView:mouse_selection(doc, snap_type, line1, col1, line2, col2)
  local swap = line2 < line1 or line2 == line1 and col2 <= col1
  if swap then
    line1, col1, line2, col2 = line2, col2, line1, col1
  end
  if snap_type == "word" then
    line1, col1 = translate.start_of_word(doc, line1, col1)
    line2, col2 = translate.end_of_word(doc, line2, col2)
  elseif snap_type == "lines" then
    col1, col2, line2 = 1, 1, line2 + 1
  end
  if swap then
    return line2, col2, line1, col1
  end
  return line1, col1, line2, col2
end


---Handle mouse press for text selection and gutter clicks.
---Supports single/double click, shift-selection, and gutter line selection.
---@param button core.view.mousebutton
---@param x number Screen x coordinate
---@param y number Screen y coordinate
---@param clicks integer Number of clicks
---@return boolean? handled True if event was handled
function DocView:on_mouse_pressed(button, x, y, clicks)
  if button == "left" then self.doc:clear_search_selections() end
  if button ~= "left" or not self.hovering_gutter then
    return DocView.super.on_mouse_pressed(self, button, x, y, clicks)
  end
  local line = self:resolve_screen_position(x, y)
  if keymap.modkeys["shift"] then
    local sline, scol, sline2, scol2 = self.doc:get_selection(true)
    if line > sline then
      self.doc:set_selection(sline, 1, line,  #self.doc.lines[line])
    else
      self.doc:set_selection(line, 1, sline2, #self.doc.lines[sline2])
    end
  else
    if clicks == 1 then
      self.doc:set_selection(line, 1, line, 1)
    elseif clicks == 2 then
      self.doc:set_selection(line, 1, line, #self.doc.lines[line])
    end
  end
  return true
end


---Handle mouse release to end text selection.
function DocView:on_mouse_released(...)
  DocView.super.on_mouse_released(self, ...)
  self.mouse_selecting = nil
end


---Handle text input from keyboard.
---@param text string Input text
function DocView:on_text_input(text)
  self.doc:clear_search_selections()
  self.doc:text_input(text)
end


---Handle IME text composition events.
---Updates IME decoration and scrolls to keep composition visible.
---@param text string Composition text
---@param start integer Selection start within composition
---@param length integer Selection length within composition
function DocView:on_ime_text_editing(text, start, length)
  self.doc:clear_search_selections()
  self.doc:ime_text_editing(text, start, length)
  self.ime_status = #text > 0
  self.ime_selection.from = start
  self.ime_selection.size = length

  -- Set the composition bounding box that the system IME
  -- will consider when drawing its interface
  local line1, col1, line2, col2 = self.doc:get_selection(true)
  local col = math.min(col1, col2)
  self:update_ime_location()
  self:scroll_to_make_visible(line1, col + start)
end


---Update IME composition window location.
---Sets the bounding box for the system IME composition window.
function DocView:update_ime_location()
  if core.active_view ~= self then return end

  local line1, col1, line2, col2 = self.doc:get_selection(true)
  if
    not self.ime_status and core.active_view == IME_VIEW
    and
    IME_STATE.line1 == line1 and IME_STATE.col1 == col1
    and
    IME_STATE.line2 == line2 and IME_STATE.col2 == col2
    and
    IME_STATE.w == self.size.x and IME_STATE.h == self.size.y
  then
    return
  end

  IME_VIEW = self
  IME_STATE.line1 = line1
  IME_STATE.col1 = col1
  IME_STATE.line2 = line2
  IME_STATE.col2 = col2
  IME_STATE.w = self.size.x
  IME_STATE.h = self.size.y

  local x, y = self:get_line_screen_position(line1)
  local h = self:get_line_height()
  local col = math.min(col1, col2)

  local x1, x2 = 0, 0

  if self.ime_selection.size > 0 then
    -- focus on a part of the text
    local from = col + self.ime_selection.from
    local to = from + self.ime_selection.size
    x1 = self:get_col_x_offset(line1, from)
    x2 = self:get_col_x_offset(line1, to)
  else
    -- focus the whole text
    x1 = self:get_col_x_offset(line1, col1)
    x2 = self:get_col_x_offset(line2, col2)
  end

  ime.set_location(x + x1, y, x2 - x1, h)
end


---Update the view state each frame.
---Handles cache invalidation, auto-scrolling to caret, and blink timing.
function DocView:update()
  local line1, col1, line2, col2 = self.doc:get_selection()
  local change_id = self.doc.get_change_id and self.doc:get_change_id()
  if self.visual_lines_change_id ~= change_id then
    self.visual_lines_change_id = change_id
    self:invalidate_visual_lines(math.min(line1, self.last_line1 or line1))
  end

  -- clear cache if font or indent size changed
  local font = self:get_font()
  local _, indent_size = self.doc:get_indent_info()
  if
    self.cache_indent_size ~= indent_size
    or
    self.cache_font ~= font or self.cache_font_size ~= font:get_size()
  then
    self.doc.cache.col_x = {}
    self.cache_font = font
    self.cache_font_size = font:get_size()
    self.cache_indent_size = indent_size
  end

  -- scroll to make caret visible and reset blink timer if it moved
  if (line1 ~= self.last_line1 or col1 ~= self.last_col1 or
      line2 ~= self.last_line2 or col2 ~= self.last_col2) and self.size.x > 0 then
    if core.active_view == self and not ime.editing then
      self:scroll_to_make_visible(line1, col1)
    end
    core.blink_reset()
    self.last_line1, self.last_col1 = line1, col1
    self.last_line2, self.last_col2 = line2, col2
  end

  -- update blink timer
  if not config.disable_blink and system.window_has_focus(core.window) and self == core.active_view and not self.mouse_selecting then
    local T, t0 = config.blink_period, core.blink_start
    local ta, tb = core.blink_timer, system.get_time()
    if ((tb - t0) % T < T / 2) ~= ((ta - t0) % T < T / 2) then
      core.redraw = true
    end
    core.blink_timer = tb
  end

  self:update_ime_location()

  DocView.super.update(self)
end


---Draw the current line highlight bar.
---@param x number Screen x coordinate
---@param y number Screen y coordinate
function DocView:draw_line_highlight(x, y)
  local lh = self:get_line_height()
  renderer.draw_rect(x, y, self.size.x, lh, style.line_highlight)
end


---Draw the text content of a line with syntax highlighting.
---@param line integer Line number
---@param x number Screen x coordinate
---@param y number Screen y coordinate
---@return integer height Line height
function DocView:draw_line_text(line, x, y)
  local default_font = self:get_font()
  local tx, ty = x, y + self:get_line_text_y_offset()
  local _, indent_size = self.doc:get_indent_info()

  local search_selections = {}
  for _, line1, col1, line2, col2 in self.doc:get_selections(true) do
    if line == line1 and line <= line2 then
      if self.doc:is_search_selection(line1, col1, line2, col2) then
        table.insert(search_selections, {start = col1, stop = col2})
      end
    end
  end

  local col = 1
  local start_tx = tx
  local line_text = self.doc:get_utf8_line(line)
  local line_len = #line_text
  local line_ends_with_newline = line_text:byte(line_len) == 10
  local visible_col1, visible_col2
  if line_len > CACHE_LINE_LEN and #search_selections == 0 then
    local gw = self:get_gutter_width()
    local visible_width = math.max(0, self.size.x - gw)
    local left_x = math.max(0, self.scroll.x - LONG_LINE_EXTRA_PIXELS)
    local right_x = self.scroll.x + visible_width + LONG_LINE_EXTRA_PIXELS
    visible_col1 = self:get_x_offset_col(line, left_x)
    visible_col2 = utf8_char_end(line_text, self:get_x_offset_col(line, right_x))
    tx = x + self:get_col_x_offset(line, visible_col1)
    col = visible_col1
  end
  local token_start_col = 1
  for _, type, text in self.doc.highlighter:each_token(line) do
    local token_col = token_start_col
    token_start_col = token_start_col + #text
    if visible_col1 then
      local token_end = token_start_col - 1
      if token_end < visible_col1 then goto continue end
      if token_col < visible_col1 then
        text = text:sub(visible_col1 - token_col + 1)
        token_col = visible_col1
      end
      col = token_col
    end
    if #search_selections == 0 then
      local color = style.syntax[type] or style.syntax["normal"]
      local font = style.syntax_fonts[type] or default_font
      if font ~= default_font then font:set_tab_size(indent_size) end
      -- do not render newline, fixes issue #1164
      if line_ends_with_newline and token_start_col > line_len then text = text:sub(1, -2) end
      if visible_col2 then
        if col > visible_col2 then break end
        text = trim_token_to_col(text, col, visible_col2)
        col = col + #text
      end
      tx = renderer.draw_text(font, text, tx, ty, color, {tab_offset = tx - start_tx})
      if tx > self.position.x + self.size.x then break end
    else
      local font = style.syntax_fonts[type] or default_font
      if font ~= default_font then font:set_tab_size(indent_size) end
      if line_ends_with_newline and token_start_col > line_len then text = text:sub(1, -2) end
      local i = 1
      local len = #text

      local function is_selected(c)
        for _, sel in ipairs(search_selections) do
          if c >= sel.start and c < sel.stop then
            return true
          end
        end
        return false
      end

      while i <= len do
        local chunk_start = i
        local c = col
        local selected = is_selected(c)
        -- advance through contiguous characters with the same selection status
        while i <= len and is_selected(col) == selected do
          i = i + 1
          col = col + 1
        end
        local chunk = text:sub(chunk_start, i - 1)
        local color = selected and (style.search_selection_text or style.background)
          or (style.syntax[type] or style.syntax["normal"])
        tx = renderer.draw_text(font, chunk, tx, ty, color, {tab_offset = tx - start_tx})
        if tx > self.position.x + self.size.x then break end
      end
    end
    ::continue::
  end
  return self:get_line_height()
end


---Draw the caret at a position.
---@param x number Screen x coordinate
---@param y number Screen y coordinate
---@param line integer Line number (for overwrite mode char width)
---@param col integer Column number (for overwrite mode char width)
function DocView:draw_caret(x, y, line, col)
  local lh = self:get_line_height()
  if self.doc.overwrite then
    local w = self:get_font():get_width(self.doc:get_char(line, col))
    renderer.draw_rect(x, y + lh, w, style.caret_width * 2, style.caret)
  else
    renderer.draw_rect(x, y, style.caret_width, lh, style.caret)
  end
end


---Draw a complete line including highlight and selections.
---@param line integer Line number
---@param x number Screen x coordinate
---@param y number Screen y coordinate
---@return integer height Line height
function DocView:draw_line_body(line, x, y)
  -- draw highlight if any selection ends on this line
  local draw_highlight = false
  local hcl = config.highlight_current_line
  if hcl ~= false then
    for lidx, line1, col1, line2, col2 in self.doc:get_selections(false) do
      if line1 == line then
        if hcl == "no_selection" then
          if (line1 ~= line2) or (col1 ~= col2) then
            draw_highlight = false
            break
          end
        end
        draw_highlight = true
        break
      end
    end
  end
  if draw_highlight and core.active_view == self then
    self:draw_line_highlight(x + self.scroll.x, y)
  end

  -- draw selection if it overlaps this line
  local lh = self:get_line_height()
  for lidx, line1, col1, line2, col2 in self.doc:get_selections(true) do
    if line >= line1 and line <= line2 then
      local text = self.doc.lines[line]
      if line1 ~= line then col1 = 1 end
      if line2 ~= line then col2 = #text + 1 end
      local x1 = x + self:get_col_x_offset(line, col1)
      local x2 = x + self:get_col_x_offset(line, col2)
      if x1 ~= x2 then
        local selection_color = style.selection
        if self.doc:is_search_selection(line1, col1, line, col2) then
          selection_color = style.search_selection or style.caret
        end
        renderer.draw_rect(x1, y, x2 - x1, lh, selection_color)
      end
    end
  end

  -- draw line's text
  return self:draw_line_text(line, x, y)
end


---Draw the gutter with line numbers.
---@param line integer Line number
---@param x number Screen x coordinate
---@param y number Screen y coordinate
---@param width number Gutter width
---@return integer height Line height
function DocView:draw_line_gutter(line, x, y, width)
  local lh = self:get_line_height()
  if config.show_line_numbers then
    local color = style.line_number
    for _, line1, _, line2 in self.doc:get_selections(true) do
      if line >= line1 and line <= line2 then
        color = style.line_number2
        break
      end
    end
    x = x + style.padding.x
    common.draw_text(self:get_font(), color, line, "right", x, y, width, lh)
  end
  return lh
end


---Draw IME composition decoration (underline and selection).
---@param line1 integer Start line
---@param col1 integer Start column
---@param line2 integer End line
---@param col2 integer End column
function DocView:draw_ime_decoration(line1, col1, line2, col2)
  local x, y = self:get_line_screen_position(line1)
  local line_size = math.max(1, SCALE)
  local lh = self:get_line_height()

  -- Draw IME underline
  local x1 = self:get_col_x_offset(line1, col1)
  local x2 = self:get_col_x_offset(line2, col2)
  renderer.draw_rect(x + math.min(x1, x2), y + lh - line_size, math.abs(x1 - x2), line_size, style.text)

  -- Draw IME selection
  local col = math.min(col1, col2)
  local from = col + self.ime_selection.from
  local to = from + self.ime_selection.size
  x1 = self:get_col_x_offset(line1, from)
  if from ~= to then
    x2 = self:get_col_x_offset(line1, to)
    line_size = style.caret_width
    renderer.draw_rect(x + math.min(x1, x2), y + lh - line_size, math.abs(x1 - x2), line_size, style.caret)
  end
  self:draw_caret(x + x1, y, line1, col)
end


---Draw overlay elements (carets, IME decoration).
---Called after main text to draw on top.
function DocView:draw_overlay()
  if core.active_view == self then
    local minline, maxline = self:get_visible_line_range()
    -- draw caret if it overlaps this line
    local T = config.blink_period
    for _, line1, col1, line2, col2 in self.doc:get_selections() do
      local row = self:visual_row_from_position(line1, col1)
      if row >= minline and row <= maxline
      and system.window_has_focus(core.window) then
        if ime.editing then
          self:draw_ime_decoration(line1, col1, line2, col2)
        else
          if config.disable_blink
          or (core.blink_timer - core.blink_start) % T < T / 2 then
            local x, y = self:get_line_screen_position(line1, col1)
            self:draw_caret(x, y, line1, col1)
          end
        end
      end
    end
  end
end


---Draw the entire document view.
---Renders background, gutters, text, selections, carets, and scrollbars.
function DocView:draw()
  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 x, y = self:get_content_offset()
  local gw, gpad = self:get_gutter_width()
  y = y + (minline - 1) * lh + style.padding.y
  local drawn_gutters = {}
  for row = minline, maxline do
    local line = self:visual_position_from_row(row)
    if line and self.doc.lines[line] and not drawn_gutters[line] then
      local first_row = self:visual_row_from_position(line, 1)
      local line_y = y - (row - first_row) * lh
      self:draw_line_gutter(line, self.position.x, line_y, gpad and gw - gpad or gw)
      drawn_gutters[line] = true
    end
    y = y + lh
  end

  local gw, gpad = self:get_gutter_width()
  local pos = self.position
  x, y = self:get_content_offset()
  x = x + gw
  y = y + (minline - 1) * lh + style.padding.y
  -- 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)
  local drawn_lines = {}
  for row = minline, maxline do
    local line = self:visual_position_from_row(row)
    if line and self.doc.lines[line] and not drawn_lines[line] then
      local first_row = self:visual_row_from_position(line, 1)
      local line_y = y - (row - first_row) * lh
      self:draw_line_body(line, x, line_y)
      drawn_lines[line] = true
    end
    y = y + lh
  end
  self:draw_overlay()
  core.pop_clip_rect()

  self:draw_scrollbar()
end

return DocView
