-- lx-bundle:true

--- Binary viewing surface for placeholder binary documents
-- @module core.binary.viewer

local core = require("core")
local common = require("core.common")
local config = require("core.config")
local style = require("core.style")
local DocState = require("core.doc.state")
local SessionState = require("core.workspace.sessionstate")
local DocView = require("core.doc.view")
local codepage = require("core.text.codepage")

local BinaryViewer = DocView:extend()

local VSCROLL_LANE_ID = DocView.LaneIDs.VSCROLL
local DOC_STATE_NAMESPACE = "BinaryViewer"
local SESSION_MODE_KEY = "BinaryViewer.mode"

-- Binary document renderer for BinaryViewer views
-- Modes
-- - hex renders offsets + hex bytes + character area
-- - symbols renders wrapped byte symbols

-- Return the monospace font used by binary render rows
local function get_view_font()
	return style.code_font
end

-- Return render line height aligned with editor line-height config
local function get_line_height()
	return math.floor(get_view_font():get_height() * config.line_height)
end

-- Return vertical glyph offset that centers text within the row height
local function get_line_text_y_offset()
	local lh = get_line_height()
	local th = get_view_font():get_height()
	return (lh - th) / 2
end

-- Identify file-backed placeholders and generated documents that expose raw bytes
local function is_binary_doc(doc)
	return doc
		and (
			type(doc.binary_source_data) == "string"
			or (doc.is_binary_placeholder == true and type(doc.filename) == "string")
			or (doc.is_actual_file == true and type(doc.filename) == "string")
		)
end

-- Normalize BinaryViewer mode values
local function normalize_mode(mode)
	if mode == "symbols" then
		return "symbols"
	end
	return "hex"
end

-- Read current BinaryViewer session mode
local function get_session_mode()
	local default_mode = normalize_mode(config.binary_viewer_default_mode)
	return normalize_mode(SessionState.get(SESSION_MODE_KEY, default_mode))
end

-- Set current BinaryViewer session mode
local function set_session_mode(mode)
	local normalized_mode = normalize_mode(mode)
	SessionState.set(SESSION_MODE_KEY, normalized_mode)
	config.binary_viewer_default_mode = normalized_mode
end

--- Return the display mode owned by one BinaryViewer
-- @param view table binary viewer
-- @return string mode
local function get_view_mode(view)
	return normalize_mode(view and view.binary_mode or get_session_mode())
end

--- Change one BinaryViewer and optionally remember that choice for later ordinary views
-- @param view table binary viewer
-- @param mode string requested binary display mode
-- @param remember boolean|nil update the shared default
local function set_view_mode(view, mode, remember)
	local normalized_mode = normalize_mode(mode)
	view.binary_mode = normalized_mode
	if remember then
		set_session_mode(normalized_mode)
	end
end

-- Guard command handlers to active BinaryViewer instances
-- Accepting an optional 'view' keeps this reusable for capability matching
local function is_active_binary_viewer(view)
	local active_view = view or core.active_view
	return active_view
		and active_view.is
		and active_view:is(BinaryViewer)
		and is_binary_doc(active_view.doc)
end

-- Encode Unicode codepoint into UTF-8
local function utf8_from_codepoint(cp)
	if cp < 0 or cp > 0x10FFFF or (cp >= 0xD800 and cp <= 0xDFFF) then
		return "." -- Invalid
	end
	if cp < 0x80 then
		return string.char(cp)
	end
	if cp < 0x800 then
		local b1 = 0xC0 + math.floor(cp / 0x40)
		local b2 = 0x80 + (cp % 0x40)
		return string.char(b1, b2)
	end
	if cp < 0x10000 then
		local b1 = 0xE0 + math.floor(cp / 0x1000)
		local b2 = 0x80 + (math.floor(cp / 0x40) % 0x40)
		local b3 = 0x80 + (cp % 0x40)
		return string.char(b1, b2, b3)
	end
	local b1 = 0xF0 + math.floor(cp / 0x40000)
	local b2 = 0x80 + (math.floor(cp / 0x1000) % 0x40)
	local b3 = 0x80 + (math.floor(cp / 0x40) % 0x40)
	local b4 = 0x80 + (cp % 0x40)
	return string.char(b1, b2, b3, b4)
end

-- CP437 (OEM US) DOS control-byte viz codepoints (0x03: heart)
-- stylua: ignore
local OEM_CP437_CONTROL_CODEPOINTS = {
	[1] = 0x263A,	[2] = 0x263B,	[3] = 0x2665,	[4] = 0x2666,
	[5] = 0x2663,	[6] = 0x2660,	[7] = 0x2022,	[8] = 0x25D8,
	[9] = 0x25CB,	[10] = 0x25D9,	[11] = 0x2642,	[12] = 0x2640,
	[13] = 0x266A,	[14] = 0x266B,	[15] = 0x263C,	[16] = 0x25BA,
	[17] = 0x25C4,	[18] = 0x2195,	[19] = 0x203C,	[20] = 0x00B6,
	[21] = 0x00A7,	[22] = 0x25AC,	[23] = 0x21A8,	[24] = 0x2191,
	[25] = 0x2193,	[26] = 0x2192,	[27] = 0x2190,	[28] = 0x221F,
	[29] = 0x2194,	[30] = 0x25B2,	[31] = 0x25BC,	[127] = 0x2302,
}

-- Build UTF-8 control glyphs from codepoints so this file stays ASCII-safe
local OEM_CP437_CONTROL = {}

for b, cp in pairs(OEM_CP437_CONTROL_CODEPOINTS) do
	OEM_CP437_CONTROL[b] = utf8_from_codepoint(cp)
end

local function control_symbol(b)
	return OEM_CP437_CONTROL[b] or "." -- Invalid
end

local active_codepage_error = nil

local function codepage_symbol(b)
	local name = config.binary_viewer_codepage
	local ch, err = codepage.symbol(name, b)
	if err then
		if err ~= active_codepage_error then
			active_codepage_error = err
			core.error("BinaryViewer: %s", err)
		end
		return nil
	end
	active_codepage_error = nil
	return ch
end

--- Normalize the configured character render mode to supported values
-- Unsupported values fall back to 'full' so mode handling stays simple
-- @return string normalized char mode
local function get_binary_char_mode()
	if config.binary_viewer_char_mode == "printable" then
		return "printable"
	end

	return "full"
end

-- Printable-only byte to character mapping
local function byte_to_printable_symbol(b)
	if b == 0 then
		return config.binary_viewer_nul_symbol or " " -- Nul
	end

	if b >= 32 and b <= 126 then
		return string.char(b)
	end
	return "." -- Not printable
end

-- Full byte mapping including visible control-byte symbols
local function byte_to_full_symbol(b)
	if b == 0 then
		return config.binary_viewer_nul_symbol or " " -- Nul
	end
	if b < 32 or b == 127 then
		return control_symbol(b)
	end
	local mapped = codepage_symbol(b)
	if type(mapped) == "string" and mapped ~= "" then
		return mapped
	end
	if b >= 32 and b <= 126 then
		return string.char(b)
	end
	return "." -- Out of range
end

-- Switch between the supported printable and full symbol render modes
local function byte_to_render_symbol(b)
	local mode = get_binary_char_mode()

	if mode == "printable" then
		return byte_to_printable_symbol(b)
	end

	return byte_to_full_symbol(b)
end

-- Visibility check used by dim overlays
local function symbol_is_visible(sym)
	return type(sym) == "string" and sym ~= "" and sym ~= " " and sym ~= "\t"
end

-- Return the fixed symbol-cell advance used by binary byte columns
local function get_symbol_cell_advance(font)
	return math.max(1, font:get_width("M"))
end

-- Normalize and sort the allowed auto-bpr list
local function sanitize_allowed_hex_bpr()
	local list = config.binary_viewer_hex_bytes_per_row_allowed

	if type(list) ~= "table" then
		list = { 8, 16, 32, 64, 128, 256 }
	end
	local seen = {}
	local out = {}

	for i = 1, #list do
		local v = math.floor(tonumber(list[i]) or 0)

		if v > 0 and not seen[v] then
			seen[v] = true
			out[#out + 1] = v
		end
	end
	table.sort(out)

	if #out == 0 then
		out = { 8, 16, 32, 64, 128, 256 }
	end
	return out
end

-- Number of separator boundaries for a given row width
local function row_group_count(bpr)
	local group = math.floor(tonumber(config.binary_viewer_group_bytes) or 0)

	if group <= 0 then
		return 0
	end
	return math.floor((bpr - 1) / group)
end

-- Whether offset prefix is rendered for a mode
local function mode_shows_offset(mode)
	if mode == "symbols" then
		return config.binary_viewer_show_offset_binary == true
	end
	return config.binary_viewer_show_offset_hex ~= false
end

-- Reserve spacing width for hex separator lanes when enabled
local function get_hex_group_token()
	if config.binary_viewer_hex_separator == false then
		return nil
	end
	return "   "
end

-- Reserve spacing width for character separator lanes when enabled
local function get_char_group_token()
	if config.binary_viewer_char_separator == false then
		return nil
	end
	return " "
end

-- Character count used for width fitting in auto hex mode
local function row_total_char_count(bpr)
	local groups = row_group_count(bpr)
	local hex_token = get_hex_group_token()
	local char_token = get_char_group_token()
	local extra_hex = 0

	if hex_token then
		extra_hex = groups * (#hex_token - 1)
	end
	local ascii_extra = 0

	if char_token then
		ascii_extra = groups * #char_token
	end
	local hex_chars = 2 * bpr + (bpr - 1) + extra_hex
	local ascii_chars = bpr + ascii_extra
	local prefix_chars = mode_shows_offset("hex") and 12 or 0 -- "%09X:" + 2 spaces
	return prefix_chars + hex_chars + 2 + ascii_chars
end

-- Width available for text rendering excluding overlays and scrollbar
local function get_view_text_width(self)
	local right_res = 0
	local lanes = self._lane_reservations and self._lane_reservations.right
	if type(lanes) == "table" then
		for id, lane in pairs(lanes) do
			if id ~= VSCROLL_LANE_ID then
				right_res = right_res + math.max(0, tonumber(lane.size) or 0)
			end
		end
	end
	-- Keep bpr fitting stable across mode toggles by reserving vscroll width unconditionally
	right_res = right_res + math.max(1, tonumber(style.scrollbar_size) or 12)
	local right_limit = self.position.x + self.size.x - right_res
	local left = self.position.x + style.padding.x
	local right = right_limit - style.padding.x
	return math.max(1, right - left)
end

-- Pick the largest allowed bpr that fits current viewport width
local function pick_auto_hex_bpr(self, font)
	local allowed = sanitize_allowed_hex_bpr()
	local width = get_view_text_width(self)
	local best = allowed[1]

	for i = 1, #allowed do
		local bpr = allowed[i]
		local sample_chars = row_total_char_count(bpr)
		local sample = string.rep("0", sample_chars)
		local w = font:get_width(sample)

		if w <= width then
			best = bpr
		else
			break
		end
	end
	return best
end

-- Resolve configured hex bytes-per-row with auto fallback
local function get_hex_bpr(self, font)
	local raw = config.binary_viewer_hex_bytes_per_row

	if raw == "auto" then
		return pick_auto_hex_bpr(self, font)
	end
	local n = math.floor(tonumber(raw) or 0)

	if n <= 0 then
		return pick_auto_hex_bpr(self, font)
	end
	return n
end

-- Compute symbol mode bytes-per-row from viewport width
local function get_bytes_per_row_symbols(self, font)
	local min_bytes = config.binary_viewer_symbols_min_bytes_per_row
	local avail = get_view_text_width(self)

	if mode_shows_offset("symbols") then
		local prefix_w = font:get_width("000000000:  ")
		avail = math.max(1, avail - prefix_w)
	end
	local cw = get_symbol_cell_advance(font)
	local fit = math.floor(avail / cw)
	return math.max(min_bytes, fit)
end

-- Resolve file size through native metadata so provider paths never need C streams
local function detect_file_size(filename)
	local info, info_error = system.get_file_info(filename)

	if info and tonumber(info.size) ~= nil then
		return tonumber(info.size)
	end

	return nil, tostring(info_error or "failed to read file size")
end

-- Get or initialize BinaryViewer state for one doc
local function get_doc_state(doc)
	local doc_state = DocState.get(doc, DOC_STATE_NAMESPACE)
	if doc_state then
		return doc_state
	end

	doc_state = {
		mode = get_session_mode(),
		file_size = 0,
		load_error = nil,
		cache_start = 0,
		cache_data = "",
		cache_end = 0,
		cache_generation = 0,
		row_cache_signature = false,
		row_cache_rows = {},
		row_cache_count = 0,
	}

	if is_binary_doc(doc) then
		if type(doc.binary_source_data) == "string" then
			doc_state.file_size = #doc.binary_source_data
		else
			local size, size_error = detect_file_size(doc.filename)
			if size == nil then
				doc_state.load_error = size_error or "failed to stat file"
			else
				doc_state.file_size = size
			end
		end
	end

	DocState.set(doc, DOC_STATE_NAMESPACE, doc_state)
	return doc_state
end

--- Reset all cached formatted binary rows for one doc state
-- This keeps row text rebuilding tied to layout and render settings changes
-- @param st table binary doc state
local function clear_row_cache(st)
	st.row_cache_signature = false
	st.row_cache_rows = {}
	st.row_cache_count = 0
end

-- Remove BinaryViewer state for one doc
local function reset_doc_state(doc)
	DocState.remove(doc, DOC_STATE_NAMESPACE)
end

--- Return the row grid currently used to map screen rows to byte offsets
-- The basis keeps one exact byte offset on a row boundary after layout changes
-- @param self table binary viewer
-- @param st table binary document state
-- @param bytes_per_row integer current row width
-- @return table grid row mapping
local function get_binary_row_grid(self, st, bytes_per_row)
	local anchor = self._binary_anchor
	local file_size = math.max(0, tonumber(st.file_size) or 0)
	local basis_offset = 0
	local basis_row = 0

	if anchor
		and anchor.mode == st.mode
		and anchor.bytes_per_row == bytes_per_row
		and anchor.file_size == file_size
		and tonumber(anchor.basis_offset) ~= nil
		and tonumber(anchor.basis_row) ~= nil
	then
		basis_offset = common.clamp(math.floor(anchor.basis_offset), 0, math.max(0, file_size - 1))
		local expected_basis_row = math.ceil(basis_offset / bytes_per_row)

		if math.floor(anchor.basis_row) == expected_basis_row then
			basis_row = expected_basis_row
		else
			basis_offset = 0
		end
	end

	local remaining = math.max(0, file_size - basis_offset)
	local rows_after_basis = math.max(1, math.ceil(remaining / bytes_per_row))

	return {
		basis_offset = basis_offset,
		basis_row = basis_row,
		rows = math.max(1, basis_row + rows_after_basis),
	}
end

--- Return the absolute byte offset at one zero-based row in a row grid
-- @param grid table binary row grid
-- @param row_index integer zero-based row index
-- @param bytes_per_row integer current row width
-- @return integer offset absolute byte offset
local function get_grid_row_offset(grid, row_index, bytes_per_row)
	local offset = grid.basis_offset + (row_index - grid.basis_row) * bytes_per_row

	return math.max(0, math.floor(offset))
end

--- Return the absolute byte range covered by one zero-based row
-- @param grid table binary row grid
-- @param row_index integer zero-based row index
-- @param bytes_per_row integer current row width
-- @param file_size integer binary file size
-- @return integer start_offset absolute first byte
-- @return integer end_offset absolute last byte
local function get_grid_row_range(grid, row_index, bytes_per_row, file_size)
	if file_size <= 0 then
		return 0, -1
	end

	local start_offset = math.min(file_size, get_grid_row_offset(grid, row_index, bytes_per_row))
	local next_offset = get_grid_row_offset(grid, row_index + 1, bytes_per_row)
	local end_offset = math.min(file_size - 1, next_offset - 1)

	return start_offset, math.max(start_offset - 1, end_offset)
end

--- Return bytes-per-row, total row count, file size, and current row grid
-- @param self table binary viewer
-- @param st table binary document state
-- @param font renderer.font binary view font
-- @return integer bytes_per_row current row width
-- @return integer rows total row count
-- @return integer file_size binary file size
-- @return table grid current row grid
local function get_row_metrics(self, st, font)
	local bpr

	if st.mode == "symbols" then
		bpr = get_bytes_per_row_symbols(self, font)
	else
		bpr = get_hex_bpr(self, font)
	end
	bpr = math.max(1, math.floor(tonumber(bpr) or 1))
	local n = st.file_size or 0
	local grid = get_binary_row_grid(self, st, bpr)

	return bpr, grid.rows, n, grid
end

-- Ensure cached bytes cover the requested visible range with prefetch
local function ensure_cached_range(doc, st, start_off, end_off, bpr)
	-- Reject cache work when this doc is unavailable for binary rendering

	if st.load_error or not is_binary_doc(doc) then
		return
	end

	-- Empty files keep an empty cache model
	if st.file_size <= 0 then
		st.cache_start = 0
		st.cache_end = 0
		st.cache_data = ""
		st.cache_generation = st.cache_generation + 1
		return
	end

	start_off = math.max(0, math.floor(start_off or 0))
	end_off = math.min(st.file_size - 1, math.floor(end_off or (st.file_size - 1)))

	if end_off < start_off then
		st.cache_start = 0
		st.cache_end = 0
		st.cache_data = ""
		st.cache_generation = st.cache_generation + 1
		return
	end

	-- Reuse existing cache when the requested interval is already covered
	if #st.cache_data > 0 and start_off >= st.cache_start and end_off < st.cache_end then
		return
	end

	-- Expand requested interval with symmetric prefetch to reduce disk churn
	local prefetch = math.max(1, math.floor(tonumber(config.binary_viewer_prefetch_rows) or 80)) * math.max(1, bpr)
	local want_start = math.max(0, start_off - prefetch)
	local want_end = math.min(st.file_size - 1, end_off + prefetch)
	local want_len = want_end - want_start + 1

	-- Generated documents keep their bytes in memory; file placeholders use the native path API
	local data
	local read_error

	if type(doc.binary_source_data) == "string" then
		data = doc.binary_source_data:sub(want_start + 1, want_start + want_len)
	else
		data, read_error = system.read_file(doc.filename, want_len, want_start)
	end

	if not data then
		st.load_error = tostring(read_error or "read failed")

		return
	end

	-- Replace cache window atomically after a successful read
	st.cache_start = want_start
	st.cache_data = data
	st.cache_end = want_start + #data
	st.cache_generation = st.cache_generation + 1
end

-- Fetch one cached byte by absolute offset
local function byte_at(st, off)
	local idx = off - st.cache_start + 1

	if idx < 1 or idx > #st.cache_data then
		return nil
	end
	return st.cache_data:byte(idx)
end

--- Check whether one formatted row is fully covered by the current byte cache
-- Complete rows stay reusable across cache window shifts because binary docs are immutable
-- @param st table binary doc state
-- @param start_offset integer absolute first byte
-- @param end_offset integer absolute last byte
-- @return boolean row cache coverage state
local function is_row_fully_cached(st, start_offset, end_offset)
	if end_offset < start_offset then
		return true
	end

	return start_offset >= st.cache_start and end_offset < st.cache_end
end

--- Build the row-cache signature for the current BinaryDoc formatting state
-- This captures every setting that changes formatted row text or its cached width
-- @param st table binary doc state
-- @param font renderer.font binary view font
-- @param bpr integer bytes per row
-- @return string row-cache signature
local function get_row_cache_signature(st, font, bpr)
	local null_symbol = config.binary_viewer_null_symbol

	if null_symbol == nil then
		null_symbol = "<nil>"
	end

	return table.concat({
		st.mode,
		tostring(bpr),
		get_binary_char_mode(),
		tostring(config.binary_viewer_codepage or ""),
		tostring(math.floor(tonumber(config.binary_viewer_group_bytes) or 0)),
		(config.binary_viewer_hex_separator == false) and "0" or "1",
		(config.binary_viewer_char_separator == false) and "0" or "1",
		mode_shows_offset(st.mode) and "1" or "0",
		tostring(null_symbol),
		tostring(font:get_width("M")),
		tostring(font:get_height()),
	}, "|")
end

--- Return the active per-doc row cache after invalidating stale formatting signatures
-- This keeps cached row text aligned with the current mode, layout, and font metrics
-- @param st table binary doc state
-- @param font renderer.font binary view font
-- @param bpr integer bytes per row
-- @return table row cache table
local function get_row_cache(st, font, bpr)
	local signature = get_row_cache_signature(st, font, bpr)

	if st.row_cache_signature ~= signature then
		clear_row_cache(st)
		st.row_cache_signature = signature
	end

	return st.row_cache_rows
end

--- Return the maximum cached BinaryDoc rows retained per doc
-- The cache stays bounded so long binary scroll sessions do not accumulate unbounded row text
-- @return integer maximum cached row entries
local function get_row_cache_limit()
	local prefetch_rows = math.max(1, math.floor(tonumber(config.binary_viewer_prefetch_rows) or 80))
	return math.max(256, prefetch_rows * 8)
end

--- Build one hex-mode row text and a matching zero-byte mask
-- @param st table binary document state
-- @param start_offset integer absolute first byte
-- @param end_offset integer absolute last byte
-- @param bpr integer bytes per row
-- @return string prefix formatted hex prefix
-- @return string zero_prefix dim overlay prefix
-- @return string symbols_text formatted character cells
-- @return string zero_symbols_text dim character overlay
-- @return boolean has_nul_overlay whether the dim overlay has content
local function format_hex_row(st, start_offset, end_offset, bpr)
	local group = math.floor(tonumber(config.binary_viewer_group_bytes) or 0)
	local hex_token = get_hex_group_token()
	local char_token = get_char_group_token()
	local available = math.max(0, end_offset - start_offset + 1)

	if available <= 0 then
		return "", "", "", "", false
	end
	local addr = string.format("%09X:", start_offset)

	local hex_parts = {}
	local ascii_parts = {}
	local hex_zero_parts = {}
	local ascii_zero_parts = {}
	local has_nul_overlay = false

	-- Fixed field width keeps the right character pane aligned
	local function full_hex_field_width()
		local w = 0

		for i = 0, bpr - 1 do
			w = w + 2 -- one byte as "XX"

			if i < bpr - 1 then
				if group > 0 and ((i + 1) % group == 0) then
					if hex_token then
						w = w + #hex_token
					else
						w = w + 1
					end
				else
					w = w + 1
				end
			end
		end
		return w
	end

	-- Render real bytes first, then add grouping spacers
	for i = 0, available - 1 do
		local off = start_offset + i
		local b = byte_at(st, off)

		if b == nil then
			-- Missing cache bytes are represented as unknown placeholders
			hex_parts[#hex_parts + 1] = "??"
			ascii_parts[#ascii_parts + 1] = "." -- Invalid
			hex_zero_parts[#hex_zero_parts + 1] = "  "
			ascii_zero_parts[#ascii_zero_parts + 1] = " "
		else
			local sym = byte_to_render_symbol(b)
			hex_parts[#hex_parts + 1] = string.format("%02X", b)
			ascii_parts[#ascii_parts + 1] = sym

			-- Zero-byte mask text is rendered later in a dim color overlay
			if b == 0 then
				hex_zero_parts[#hex_zero_parts + 1] = "00"
				local zero_sym = symbol_is_visible(sym) and sym or " "
				ascii_zero_parts[#ascii_zero_parts + 1] = zero_sym
				has_nul_overlay = true
			else
				hex_zero_parts[#hex_zero_parts + 1] = "  "
				ascii_zero_parts[#ascii_zero_parts + 1] = " "
			end
		end

		if i < available - 1 then
			if group > 0 and ((i + 1) % group == 0) then
				hex_parts[#hex_parts + 1] = hex_token or " "
				hex_zero_parts[#hex_zero_parts + 1] = string.rep(" ", #(hex_token or " "))

				if char_token then
					ascii_parts[#ascii_parts + 1] = char_token
					ascii_zero_parts[#ascii_zero_parts + 1] = string.rep(" ", #char_token)
				end
			else
				hex_parts[#hex_parts + 1] = " "
				hex_zero_parts[#hex_zero_parts + 1] = " "
			end
		end
	end

	-- Pad the hex field so the char pane starts at a fixed x-column
	local hex_text = table.concat(hex_parts)
	local hex_zero_text = table.concat(hex_zero_parts)
	local pad = full_hex_field_width() - #hex_text

	if pad > 0 then
		hex_text = hex_text .. string.rep(" ", pad)
		hex_zero_text = hex_zero_text .. string.rep(" ", pad)
	end
	local ascii_text = table.concat(ascii_parts)
	local ascii_zero_text = table.concat(ascii_zero_parts)
	local prefix = hex_text .. "  "
	local zero_prefix = hex_zero_text .. "  "

	-- Optional offset prefix is applied at the final assembly stage
	if mode_shows_offset("hex") then
		return addr .. "  " .. prefix, string.rep(" ", #addr + 2) .. zero_prefix, ascii_text, ascii_zero_text, has_nul_overlay
	end
	return prefix, zero_prefix, ascii_text, ascii_zero_text, has_nul_overlay
end

--- Build one symbol-mode row text and a matching zero-byte mask
-- @param st table binary document state
-- @param start_offset integer absolute first byte
-- @param end_offset integer absolute last byte
-- @param bpr integer bytes per row
-- @return string prefix optional address prefix
-- @return string zero_prefix dim address overlay
-- @return string symbols_text formatted symbol cells
-- @return string zero_symbols_text dim symbol overlay
-- @return boolean has_nul_overlay whether the dim overlay has content
local function format_symbol_row(st, start_offset, end_offset, bpr)
	-- Symbol mode has one glyph column per byte plus optional group separators
	local group = math.floor(tonumber(config.binary_viewer_group_bytes) or 0)
	local char_token = get_char_group_token()

	if end_offset < start_offset then
		return "", "", "", "", false
	end
	local addr = string.format("%09X:", start_offset)
	local parts = {}
	local zero_parts = {}
	local has_nul_overlay = false

	for off = start_offset, end_offset do
		local i = off - start_offset
		local b = byte_at(st, off)
		local sym = b and byte_to_render_symbol(b) or "." -- Invalid
		parts[#parts + 1] = sym

		-- Dim-mask only includes visible symbols for zero bytes
		if b == 0 and symbol_is_visible(sym) then
			zero_parts[#zero_parts + 1] = sym
			has_nul_overlay = true
		else
			zero_parts[#zero_parts + 1] = " "
		end

		if off < end_offset and group > 0 and ((i + 1) % group == 0) and char_token then
			parts[#parts + 1] = char_token
			zero_parts[#zero_parts + 1] = string.rep(" ", #char_token)
		end
	end
	local body = table.concat(parts)
	local zero_body = table.concat(zero_parts)

	if mode_shows_offset("symbols") then
		return addr .. "  ", string.rep(" ", #addr + 2), body, zero_body, has_nul_overlay
	end
	return "", "", body, zero_body, has_nul_overlay
end

--- Return cached formatted row data for the current BinaryDoc layout state
-- Completed rows remain reusable across cache window shifts to avoid rebuilding visible text every frame
-- @param st table binary doc state
-- @param font renderer.font binary view font
-- @param start_offset integer absolute first byte
-- @param end_offset integer absolute last byte
-- @param bpr integer bytes per row
-- @return table cached row draw data
local function get_cached_formatted_row(st, font, start_offset, end_offset, bpr)
	local row_cache = get_row_cache(st, font, bpr)
	local stored_row = row_cache[start_offset]
	local cached_row = stored_row

	if cached_row and cached_row.end_offset ~= end_offset then
		cached_row = nil
	end

	if cached_row and (cached_row.complete or cached_row.cache_generation == st.cache_generation) then
		return cached_row
	end

	local prefix, zero_prefix, symbols_text, zero_symbols_text, has_nul_overlay
	local is_new_row = stored_row == nil

	if st.mode == "symbols" then
		prefix, zero_prefix, symbols_text, zero_symbols_text, has_nul_overlay =
			format_symbol_row(st, start_offset, end_offset, bpr)
	else
		prefix, zero_prefix, symbols_text, zero_symbols_text, has_nul_overlay =
			format_hex_row(st, start_offset, end_offset, bpr)
	end

	if is_new_row and st.row_cache_count >= get_row_cache_limit() then
		local signature = st.row_cache_signature
		clear_row_cache(st)
		st.row_cache_signature = signature
		row_cache = st.row_cache_rows
		is_new_row = true
	end

	cached_row = {
		prefix = prefix,
		zero_prefix = zero_prefix,
		symbols_text = symbols_text,
		zero_symbols_text = zero_symbols_text,
		has_nul_overlay = has_nul_overlay,
		prefix_width = font:get_width(prefix),
		end_offset = end_offset,
		complete = is_row_fully_cached(st, start_offset, end_offset),
		cache_generation = st.cache_generation,
	}
	row_cache[start_offset] = cached_row

	if is_new_row then
		st.row_cache_count = st.row_cache_count + 1
	end

	return cached_row
end

-- Build the hex side ruler aligned with byte columns
local function build_hex_ruler(bpr)
	local group = math.floor(tonumber(config.binary_viewer_group_bytes) or 0)
	local hex_token = get_hex_group_token()
	local parts = {}

	for i = 0, bpr - 1 do
		local d = string.format("%X", i % 16)
		parts[#parts + 1] = " " .. d

		if i < bpr - 1 then
			if group > 0 and ((i + 1) % group == 0) then
				if hex_token then
					parts[#parts + 1] = string.rep(" ", #hex_token)
				else
					parts[#parts + 1] = " "
				end
			else
				parts[#parts + 1] = " "
			end
		end
	end
	return table.concat(parts)
end

-- Build the character side ruler aligned with rendered symbols
local function build_char_ruler(bpr)
	local group = math.floor(tonumber(config.binary_viewer_group_bytes) or 0)
	local char_token = get_char_group_token()
	local parts = {}

	for i = 0, bpr - 1 do
		parts[#parts + 1] = string.format("%X", i % 16)

		if i < bpr - 1 and group > 0 and ((i + 1) % group == 0) and char_token then
			parts[#parts + 1] = string.rep(" ", #char_token)
		end
	end
	return table.concat(parts)
end

-- Optional mode label shown in ruler area when offsets are visible
local function build_mode_label(mode)
	if mode == "symbols" then
		return mode_shows_offset(mode) and "   Binary " or ""
	end
	return mode_shows_offset(mode) and "      Hex " or ""
end

-- Prefix a ruler with mode label spacing
local function with_mode_prefix(mode, body)
	local left = build_mode_label(mode)

	if left == "" then
		return body
	end
	return left .. "  " .. body
end

-- Complete ruler line for hex mode
local function build_hex_mode_ruler(mode, bpr)
	local hex_ruler = build_hex_ruler(bpr)
	local char_ruler = build_char_ruler(bpr)
	return with_mode_prefix(mode, hex_ruler .. "  " .. char_ruler)
end

-- Complete ruler line for symbol mode
local function build_symbols_mode_ruler(mode, bpr)
	local char_ruler = build_char_ruler(bpr)
	return with_mode_prefix(mode, char_ruler)
end

-- Dispatch ruler builder by mode
local function build_ruler_line(mode, bpr)
	if mode == "symbols" then
		return build_symbols_mode_ruler(mode, bpr)
	end
	return build_hex_mode_ruler(mode, bpr)
end

-- Compute separator lane columns in character-grid space
local function collect_separator_columns(mode, bpr)
	local group = math.floor(tonumber(config.binary_viewer_group_bytes) or 0)

	if group <= 0 then
		return {}
	end

	local cols = {}
	local col = mode_shows_offset(mode) and 12 or 0 -- "%09X:" + 2 spaces

	local function at_boundary(i)
		return i < bpr and (i % group == 0)
	end

	if mode == "hex" then
		local hex_token = get_hex_group_token()

		for i = 1, bpr do
			col = col + 2

			if i < bpr then
				if at_boundary(i) then
					local tok = hex_token or " "

					if hex_token then
						cols[#cols + 1] = col + math.floor(#tok / 2)
					end
					col = col + #tok
				else
					col = col + 1
				end
			end
		end

		-- Skip the fixed gap between hex bytes and character pane
		col = col + 2
		local char_token = get_char_group_token()

		for i = 1, bpr do
			col = col + 1

			if at_boundary(i) and char_token then
				cols[#cols + 1] = col + math.floor(#char_token / 2)
				col = col + #char_token
			end
		end
	else
		local char_token = get_char_group_token()

		for i = 1, bpr do
			col = col + 1

			if at_boundary(i) and char_token then
				cols[#cols + 1] = col + math.floor(#char_token / 2)
				col = col + #char_token
			end
		end
	end

	return cols
end

-- Draw all separator lanes as contiguous vertical lines in one pass
local function draw_separator_lines(self, st, font, bpr, base_x, top_pad)
	local cols = collect_separator_columns(st.mode, bpr)

	if #cols == 0 then
		return
	end

	local cw = math.max(1, font:get_width(" "))
	local y = self.position.y + top_pad
	local h = math.max(0, self.size.y - top_pad)

	if h <= 0 then
		return
	end

	local color = style.line_number2 or style.dim or style.text

	for i = 1, #cols do
		local x = math.floor(base_x + cols[i] * cw + cw * 0.5)
		renderer.draw_rect(x, y, 1, h, color)
	end
end

-- Maximum vertical scroll for current binary view
local function get_binary_max_scroll(self)
	-- Reuse the shared viewport-aware scroll limit so resize and scrollbar visibility stay consistent
	return self:get_scroll_max_y()
end

-- Top padding accounts for optional ruler row
local function get_binary_top_pad(self)
	if config.binary_viewer_show_ruler then
		return get_line_height() + style.padding.y
	end
	return style.padding.y
end

-- Main binary renderer draw path for BinaryViewer
local function draw_binary_doc(self)
	-- Resolve state and metrics for this view's own display mode
	local st = get_doc_state(self.doc)
	st.mode = get_view_mode(self)
	local font = get_view_font()
	local lh = get_line_height()
	local yoffset = get_line_text_y_offset()

	if st.load_error then
		local msg = "Binary render error: " .. tostring(st.load_error)
		renderer.draw_text(
			font,
			msg,
			self.position.x + style.padding.x,
			self.position.y + style.padding.y,
			style.accent or style.text
		)
		return
	end

	local bpr, rows, n, grid = get_row_metrics(self, st, font)

	-- Draw ruler as a fixed top strip outside the scrolled content clip
	if config.binary_viewer_show_ruler then
		local header = build_ruler_line(st.mode, bpr)
		renderer.draw_text(
			font,
			header,
			self.position.x + style.padding.x,
			self.position.y + style.padding.y,
			style.line_number2 or style.text
		)
	end

	local top_pad = get_binary_top_pad(self)
	local min_row = math.max(1, math.floor((self.scroll.y or 0) / lh) + 1)
	local max_row = math.min(rows, math.floor(((self.scroll.y or 0) + self.size.y - top_pad) / lh) + 1)

	-- Cache only bytes around the currently visible rows
	if n > 0 and max_row >= min_row then
		local start_off = get_grid_row_range(grid, min_row - 1, bpr, n)
		local _, end_off = get_grid_row_range(grid, max_row - 1, bpr, n)
		ensure_cached_range(self.doc, st, start_off, end_off, bpr)
	end

	local base_x = self.position.x + style.padding.x
	local base_y = self.position.y - self.scroll.y + top_pad
	local symbol_cell_advance = get_symbol_cell_advance(font)

	-- Clip everything below the ruler and render only visible rows
	core.push_clip_rect(self.position.x, self.position.y + top_pad, self.size.x, self.size.y - top_pad)

	for row = min_row, max_row do
		local start_offset, end_offset = get_grid_row_range(grid, row - 1, bpr, n)
		local cached_row = get_cached_formatted_row(st, font, start_offset, end_offset, bpr)
		local y = base_y + (row - 1) * lh
		local symbol_x = base_x + cached_row.prefix_width

			if cached_row.prefix ~= "" then
				renderer.draw_text(font, cached_row.prefix, base_x, y + yoffset, style.text)
			end
			renderer.draw_text_cells(font, cached_row.symbols_text, symbol_x, y + yoffset, style.text, symbol_cell_advance)

		-- Draw zero-byte dim layer after the base text pass
		if config.binary_viewer_dim_nul_bytes and cached_row.has_nul_overlay then
			local bar_color = style.dim or style.line_number2 or style.text

			if cached_row.zero_prefix ~= "" then
				renderer.draw_text(font, cached_row.zero_prefix, base_x, y + yoffset, bar_color)
			end

				renderer.draw_text_cells(font, cached_row.zero_symbols_text, symbol_x, y + yoffset, bar_color, symbol_cell_advance)
			end
	end

	-- Draw separator lanes last so they remain contiguous across rows
	draw_separator_lines(self, st, font, bpr, base_x, top_pad)
	core.pop_clip_rect()

end

-- Shared scroll layout values derived from viewport and row geometry
local function get_binary_scroll_layout(self)
	-- Centralized layout model used by all scroll calculations
	local st = get_doc_state(self.doc)
	local font = get_view_font()
	local bpr, rows, _, grid = get_row_metrics(self, st, font)
	local lh = math.max(1, get_line_height())
	local top_pad = get_binary_top_pad(self)
	local viewport_h = math.max(1, self.size.y - top_pad)
	local visible_rows = math.max(1, math.floor(viewport_h / lh))
	local max_scroll = get_binary_max_scroll(self)
	local max_top_row = math.max(1, rows - visible_rows + 1, math.floor(max_scroll / lh) + 1)
	return {
		bpr = bpr,
		rows = rows,
		lh = lh,
		top_pad = top_pad,
		visible_rows = visible_rows,
		max_top_row = max_top_row,
		max_scroll = max_scroll,
		grid = grid,
	}
end

-- Snap scroll target to row boundaries with optional bottom-lock bias
local function snap_binary_scroll(self, y, prefer_bottom)
	-- Row snapping removes half-line states and keeps mode switches stable
	local l = get_binary_scroll_layout(self)
	local next_y = y
	next_y = common.clamp(next_y, 0, l.max_scroll)
	-- Keep explicit top/bottom jumps exact so home/end matches viewport bounds
	if next_y <= 0 then
		return 0
	end
	if prefer_bottom and next_y >= l.max_scroll then
		return l.max_scroll
	end
	local row_float = (next_y / l.lh) + 1
	local top_row = common.clamp(math.floor(row_float + 0.5), 1, l.max_top_row)

	if prefer_bottom and top_row >= l.max_top_row then
		return l.max_scroll
	end
	local row_y = (top_row - 1) * l.lh
	return common.clamp(row_y, 0, l.max_scroll)
end

local restore_binary_layout_anchor

--- Scroll by pixel delta then snap to nearest row boundary
-- @param self table binary viewer
-- @param delta number pixel delta
-- @param animate boolean|nil explicit animation choice; nil uses binary smooth-scroll config
local function scroll_by(self, delta, animate)
	restore_binary_layout_anchor(self, get_view_mode(self))
	local next_y = (self.scroll.to.y or self.scroll.y or 0) + delta
	next_y = snap_binary_scroll(self, next_y, false)
	self.scroll.to.y = next_y
	self._binary_scroll_animated = animate

	if animate == false then
		self.scroll.y = next_y
	end

	core.redraw = true
end

--- Jump to one normalized position and snap to rows
-- @param self table binary viewer
-- @param t number normalized position from 0 to 1
local function scroll_to_ratio(self, t)
	restore_binary_layout_anchor(self, get_view_mode(self))
	local max_scroll = get_binary_max_scroll(self)
	local next_y = common.clamp(max_scroll * common.clamp(t, 0, 1), 0, max_scroll)
	next_y = snap_binary_scroll(self, next_y, true)
	self:set_vertical_edge_scroll_target(next_y)
	self._binary_scroll_animated = config.animate_vertical_edge_scroll == true
	core.redraw = true
end

--- Check whether an anchor describes the current visible row layout
-- @param self table binary viewer
-- @param anchor table|nil binary anchor
-- @param mode string current display mode
-- @param layout table current binary layout
-- @return boolean matches true when the layout is unchanged
local function binary_anchor_layout_matches(self, anchor, mode, layout)
	local st = get_doc_state(self.doc)
	local valid_state = false

	if anchor
		and tonumber(anchor.basis_offset) ~= nil
		and tonumber(anchor.basis_row) ~= nil
		and tonumber(anchor.scroll_y) ~= nil
		and tonumber(anchor.line_height) ~= nil
		and tonumber(anchor.bytes_per_row) ~= nil
		and anchor.line_height > 0
		and anchor.bytes_per_row > 0
	then
		local anchor_row = math.max(0, math.floor(anchor.scroll_y / anchor.line_height))
		local expected_offset = get_grid_row_offset(anchor, anchor_row, anchor.bytes_per_row)
		local last_offset = math.max(0, (st.file_size or 1) - 1)
		valid_state = common.clamp(expected_offset, 0, last_offset) == anchor.offset
	end

	return valid_state
		and anchor.mode == mode
		and anchor.bytes_per_row == layout.bpr
		and anchor.file_size == st.file_size
		and math.abs((anchor.text_width or 0) - get_view_text_width(self)) < 0.01
		and math.abs((anchor.view_height or 0) - self.size.y) < 0.01
		and math.abs((anchor.line_height or 0) - layout.lh) < 0.01
		and math.abs((anchor.top_pad or 0) - layout.top_pad) < 0.01
end

--- Store the one shared byte anchor and its current row-grid basis
-- @param self table binary viewer
-- @param offset integer absolute byte shown on the top row
-- @param mode string current display mode
-- @param layout table current binary layout
-- @param basis_offset integer absolute byte at the basis row
-- @param scroll_y number current vertical scroll position
-- @param target_y number target vertical scroll position
local function set_binary_anchor(self, offset, mode, layout, basis_offset, scroll_y, target_y)
	local st = get_doc_state(self.doc)
	local last_offset = math.max(0, (st.file_size or 1) - 1)
	offset = common.clamp(math.floor(tonumber(offset) or 0), 0, last_offset)
	basis_offset = common.clamp(math.floor(tonumber(basis_offset) or offset), 0, last_offset)
	local basis_row = math.ceil(basis_offset / math.max(1, math.floor(tonumber(layout.bpr) or 1)))
	scroll_y = tonumber(scroll_y) or 0
	target_y = tonumber(target_y) or scroll_y

	self._binary_anchor = {
		offset = offset,
		mode = normalize_mode(mode),
		bytes_per_row = math.max(1, math.floor(tonumber(layout.bpr) or 1)),
		file_size = math.max(0, tonumber(st.file_size) or 0),
		basis_offset = basis_offset,
		basis_row = basis_row,
		text_width = get_view_text_width(self),
		view_height = self.size.y,
		line_height = math.max(1, tonumber(layout.lh) or get_line_height()),
		top_pad = math.max(0, tonumber(layout.top_pad) or get_binary_top_pad(self)),
		scroll_y = scroll_y,
		target_y = target_y,
	}
end

--- Preserve one exact visible byte anchor when changing binary row layouts
-- @param self table binary viewer
-- @param from_mode string source display mode
-- @param to_mode string destination display mode
-- @param anchor_offset number|nil exact byte to keep visible
local function sync_scroll_between_modes(self, from_mode, to_mode, anchor_offset)
	if not (self and self.doc and is_binary_doc(self.doc)) then
		return
	end

	local st = get_doc_state(self.doc)
	local top_byte_offset = tonumber(anchor_offset)
	from_mode = normalize_mode(from_mode)
	to_mode = normalize_mode(to_mode)

	if top_byte_offset == nil then
		-- Fallback when toggled before first draw pass for this doc
		st.mode = from_mode
		local source_layout = get_binary_scroll_layout(self)
		local current_y = self.scroll.y or 0
		local top_row = math.max(0, math.floor(current_y / source_layout.lh))
		top_byte_offset = get_grid_row_offset(source_layout.grid, top_row, source_layout.bpr)
	end

	local n = st.file_size or 0

	if n > 0 then
		top_byte_offset = common.clamp(math.floor(top_byte_offset), 0, n - 1)
	else
		top_byte_offset = 0
	end

	st.mode = to_mode
	local font = get_view_font()
	local bytes_per_row = get_row_metrics(self, st, font)
	local line_height = math.max(1, get_line_height())
	local top_pad = get_binary_top_pad(self)
	local basis_row = math.ceil(top_byte_offset / bytes_per_row)
	local initial_y = basis_row * line_height
	local preliminary_layout = {
		bpr = bytes_per_row,
		lh = line_height,
		top_pad = top_pad,
	}

	set_binary_anchor(
		self,
		top_byte_offset,
		to_mode,
		preliminary_layout,
		top_byte_offset,
		initial_y,
		initial_y
	)

	local layout = get_binary_scroll_layout(self)
	local new_y = common.clamp(initial_y, 0, layout.max_scroll)

	self.scroll.to.y = new_y
	self.scroll.y = new_y
	self._binary_scroll_animated = false
	set_binary_anchor(self, top_byte_offset, to_mode, layout, top_byte_offset, new_y, new_y)
end

--- Return the exact byte anchor for the current binary position
-- A projected position reuses the retained byte so layout rounding cannot drift
-- @param self table binary viewer
-- @param mode string current display mode
-- @return number offset exact visible byte anchor
local function get_current_top_byte_offset(self, mode)
	if not (self and self.doc and is_binary_doc(self.doc)) then
		return 0
	end

	local st = get_doc_state(self.doc)
	mode = normalize_mode(mode)
	st.mode = mode
	local layout = get_binary_scroll_layout(self)
	local current_y = self.scroll.y or 0
	local target_y = self.scroll.to.y or current_y
	local retained = self._binary_anchor
	local layout_matches = binary_anchor_layout_matches(self, retained, mode, layout)

	if retained and not layout_matches then
		sync_scroll_between_modes(self, retained.mode, mode, retained.offset)
		local last_offset = math.max(0, (st.file_size or 1) - 1)

		return common.clamp(retained.offset or 0, 0, last_offset)
	end

	local top_row = math.max(0, math.floor(current_y / layout.lh))
	local last_offset = math.max(0, (st.file_size or 1) - 1)
	local offset = common.clamp(get_grid_row_offset(layout.grid, top_row, layout.bpr), 0, last_offset)
	local basis_offset = layout.grid.basis_offset

	set_binary_anchor(self, offset, mode, layout, basis_offset, current_y, target_y)

	return offset
end

--- Reproject the shared byte anchor when the current row layout changes
-- @param self table binary viewer
-- @param mode string current display mode
restore_binary_layout_anchor = function(self, mode)
	local anchor = self._binary_anchor
	if not anchor or anchor.offset == nil then
		return
	end

	local st = get_doc_state(self.doc)
	mode = normalize_mode(mode)
	st.mode = mode
	local layout = get_binary_scroll_layout(self)

	if binary_anchor_layout_matches(self, anchor, mode, layout) then
		return
	end

	sync_scroll_between_modes(self, anchor.mode, mode, anchor.offset)
end

--- Finish direct panning on the byte row already visible at the top
-- @param self table binary viewer
-- @return boolean finished true when a pan was active
local function finish_binary_pan(self)
	if not self._mouse_pan then
		return false
	end

	local mode = get_view_mode(self)
	local st = get_doc_state(self.doc)
	st.mode = mode
	local layout = get_binary_scroll_layout(self)
	local current_y = common.clamp(self.scroll.y or 0, 0, layout.max_scroll)
	local top_row = math.max(0, math.floor(current_y / layout.lh))
	local target_y = common.clamp(top_row * layout.lh, 0, layout.max_scroll)

	self.scroll.y = target_y
	self.scroll.to.y = target_y
	self._binary_scroll_animated = false
	get_current_top_byte_offset(self, mode)

	return true
end

-- Initialize BinaryViewer as a doc-bound binary surface
function BinaryViewer:new(doc, opts)
	local requested_handler = opts and opts.open_handler
	self.binary_mode = requested_handler == "bytes" and "symbols"
		or (requested_handler == "hex" and "hex" or get_session_mode())
	local st = get_doc_state(doc)
	st.mode = self.binary_mode
	BinaryViewer.super.new(self, doc)
	self._display_name = (doc and doc.get_name) and doc:get_name() or "Binary"
	self.mouse_cursor = "arrow"
	self.can_select = false
	self.can_edit = false
	self.has_cursor_model = false
	self.draw_cursor = false
end

--- Return key binding contexts for the binary viewer surface
-- Binary bindings stay isolated from other document renderers
-- @return table contexts ordered from most specific to broadest
function BinaryViewer:get_keymap_contexts()
	return { "binary_viewer", "doc_view", "global" }
end

--- Fast-scroll by binary rows while preserving the shared visible byte anchor
-- @param direction number negative scrolls up and positive scrolls down
-- @return boolean handled true when the view accepted the request
function BinaryViewer:fast_scroll_vertical(direction)
	direction = tonumber(direction) or 0

	if direction == 0 then
		return false
	end

	local sign = (direction < 0) and -1 or 1
	local step = self:get_fast_scroll_step(config.binary_viewer_fastscroll_step_fraction)

	scroll_by(self, sign * step, false)
	get_current_top_byte_offset(self, get_view_mode(self))

	return true
end

--- Capture the exact binary byte before a content font change
-- @return table anchor binary zoom anchor
function BinaryViewer:capture_content_zoom_anchor()
	local mode = get_view_mode(self)

	return {
		binary_byte_offset = get_current_top_byte_offset(self, mode),
		binary_mode = mode,
	}
end

--- Restore the exact binary byte after a content font change
-- @param anchor table captured binary zoom anchor
-- @return boolean restored true when the binary anchor was restored
function BinaryViewer:restore_content_zoom_anchor(anchor)
	if type(anchor) ~= "table" or anchor.view ~= self or anchor.binary_byte_offset == nil then
		return false
	end

	local mode = normalize_mode(anchor.binary_mode)
	sync_scroll_between_modes(self, mode, mode, anchor.binary_byte_offset)

	return true
end

-- Return the tab/display name for this binary doc view
function BinaryViewer:get_name()
	if type(self._display_name) == "string" and self._display_name ~= "" then
		return self._display_name
	end
	if self.doc and type(self.doc.get_name) == "function" then
		return self.doc:get_name()
	end
	return "Binary"
end

--- Return total vertical scrollable size for binary content
-- Anchored layouts reserve enough trailing space to keep the anchor on the top row
-- @return number size vertical scrollable size
function BinaryViewer:get_scrollable_size()
	local st = get_doc_state(self.doc)
	st.mode = get_view_mode(self)
	local bytes_per_row, rows = get_row_metrics(self, st, get_view_font())
	local line_height = get_line_height()
	local content_h = get_binary_top_pad(self) + rows * line_height
	local anchor = self._binary_anchor

	if anchor
		and anchor.mode == st.mode
		and anchor.bytes_per_row == bytes_per_row
		and tonumber(anchor.basis_row) ~= nil
	then
		local required_height = anchor.basis_row * line_height + self:get_scroll_viewport_height()
		content_h = math.max(content_h, required_height)
	end

	return content_h
end

--- Update the shared binary anchor after scrollbar or pan interaction
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @param clicks number click count
-- @return boolean|nil handled
function BinaryViewer:on_mouse_down(button, x, y, clicks)
	restore_binary_layout_anchor(self, get_view_mode(self))
	local before_y = self.scroll.y or 0
	local before_target_y = self.scroll.to.y or before_y
	local handled = BinaryViewer.super.on_mouse_down(self, button, x, y, clicks)

	if self._mouse_pan
		or self.scroll.y ~= before_y
		or (self.scroll.to.y or self.scroll.y or 0) ~= before_target_y
	then
		get_current_top_byte_offset(self, get_view_mode(self))
	end

	return handled
end

--- Update the shared binary anchor while the scrollbar or image is dragged
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number pointer delta x
-- @param dy number pointer delta y
-- @return boolean|nil handled
function BinaryViewer:on_mouse_move(x, y, dx, dy)
	restore_binary_layout_anchor(self, get_view_mode(self))
	local before_y = self.scroll.y or 0
	local before_target_y = self.scroll.to.y or before_y
	local handled = BinaryViewer.super.on_mouse_move(self, x, y, dx, dy)

	if self.scroll.y ~= before_y
		or (self.scroll.to.y or self.scroll.y or 0) ~= before_target_y
	then
		get_current_top_byte_offset(self, get_view_mode(self))
	end

	return handled
end

-- Keep wheel scrolling row-snapped for binary rows
function BinaryViewer:on_mouse_wheel(a, b, c, d)
	local y = (d ~= nil and d) or (c ~= nil and c) or (b ~= nil and b) or a
	local wheel_delta = config.mouse_wheel_scroll
	local lines = config.mouse_wheel_scroll_lines
	if lines then
		wheel_delta = get_line_height() * lines
	end
	scroll_by(self, y * -wheel_delta)
	return true
end

--- Finish middle-drag panning without advancing to the next byte row
function BinaryViewer:on_mouse_up(button, x, y)
	if button == "middle" then
		finish_binary_pan(self)
	end

	return BinaryViewer.super.on_mouse_up(self, button, x, y)
end

--- Finish middle-drag panning when the pointer leaves the view
function BinaryViewer:on_mouse_left(...)
	finish_binary_pan(self)

	return BinaryViewer.super.on_mouse_left(self, ...)
end

--- Update binary scrolling and keep navigation targets on whole rows
function BinaryViewer:update()
	BinaryViewer.super.update(self)
	local st = get_doc_state(self.doc)
	st.mode = get_view_mode(self)
	local max_scroll = get_binary_max_scroll(self)

	if self._mouse_pan then
		-- Direct dragging owns the pixel position until release
		local pan_y = common.clamp(self.scroll.y or 0, 0, max_scroll)
		self.scroll.y = pan_y
		self.scroll.to.y = pan_y
		self._binary_scroll_animated = false
	else
		local target_y = self.scroll.to.y or self.scroll.y or 0
		target_y = snap_binary_scroll(self, target_y, true)
		self.scroll.to.y = target_y
		local animate = self._binary_scroll_animated

		if animate == nil then
			animate = config.binary_viewer_smooth_scroll ~= false
		end

		if not animate then
			self.scroll.y = target_y
		else
			self.scroll.y = common.clamp(self.scroll.y or 0, 0, max_scroll)
		end
	end

	self.scroll.to.x = 0
	self.scroll.x = 0
	self.mouse_cursor = self._mouse_pan and "grabbing" or "arrow"
end

-- Draw binary rows/rulers using native BinaryViewer render path
function BinaryViewer:draw()
	local mode = get_view_mode(self)
	local st = get_doc_state(self.doc)
	st.mode = mode
	restore_binary_layout_anchor(self, mode)

	self:draw_background(style.background)
	draw_binary_doc(self)
	self:draw_scrollbar()
	get_current_top_byte_offset(self, mode)
end

-- Expose command helper surface for core.binary.commands
BinaryViewer.is_active = is_active_binary_viewer
BinaryViewer.get_line_height = get_line_height
BinaryViewer.scroll_by = scroll_by
BinaryViewer.scroll_to_ratio = scroll_to_ratio
BinaryViewer.get_top_byte_offset = get_current_top_byte_offset
BinaryViewer.sync_scroll_between_modes = sync_scroll_between_modes

BinaryViewer.reload_doc_state = function(doc)
	reset_doc_state(doc)
	get_doc_state(doc)
end
BinaryViewer.get_session_mode = get_session_mode
BinaryViewer.set_session_mode = set_session_mode
BinaryViewer.get_mode = get_view_mode
BinaryViewer.set_mode = set_view_mode

return BinaryViewer
