-- 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 docs that were loaded as binary placeholders
local function is_binary_doc(doc)
	return doc and doc.is_binary_placeholder and doc.filename
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

-- 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
		list = { 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 with system info then file-end seek fallback
local function detect_file_size(filename)
	local info = system.get_file_info(filename)

	if info and tonumber(info.size) ~= nil then
		return tonumber(info.size)
	end
	local file_handle, open_error = io.open(filename, "rb")
	if not file_handle then
		return nil, tostring(open_error or "open failed")
	end
	local seek_result = file_handle:seek("end")
	file_handle:close()
	if not seek_result then
		return nil, "seek failed"
	end
	return tonumber(seek_result) or 0
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
		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

	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 bytes-per-row, total row count, and file size for current mode
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
	local n = st.file_size or 0
	local rows = math.max(1, math.ceil(n / math.max(1, bpr)))
	return bpr, rows, n
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

	-- Open and read only the required window
	local fp, err = io.open(doc.filename, "rb")

	if not fp then
		st.load_error = tostring(err or "open failed")
		return
	end

	local ok_seek = fp:seek("set", want_start)

	if not ok_seek then
		fp:close()
		st.load_error = "seek failed"
		return
	end

	local data = fp:read(want_len) or ""
	fp:close()

	-- 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 row_idx integer one-based row index
-- @param bpr integer bytes per row
-- @return boolean row cache coverage state
local function is_row_fully_cached(st, row_idx, bpr)
	local row_start = (row_idx - 1) * bpr
	local row_available = math.max(0, math.min(bpr, st.file_size - row_start))

	if row_available <= 0 then
		return true
	end

	local row_end = row_start + row_available - 1
	return row_start >= st.cache_start and row_end < 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
local function format_hex_row(st, row_idx, bpr)
	-- Build one row from zero-based row index and row width
	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 row_off = (row_idx - 1) * bpr
	local available = math.max(0, math.min(bpr, st.file_size - row_off))

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

	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 = row_off + 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
local function format_symbol_row(st, row_idx, 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()
	local start_off = (row_idx - 1) * bpr
	local end_off = math.min(st.file_size - 1, start_off + bpr - 1)

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

	for off = start_off, end_off do
		local i = off - start_off
		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_off 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 row_idx integer one-based row index
-- @param bpr integer bytes per row
-- @return table cached row draw data
local function get_cached_formatted_row(st, font, row_idx, bpr)
	local row_cache = get_row_cache(st, font, bpr)
	local cached_row = row_cache[row_idx]

	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 = row_cache[row_idx] == nil

	if st.mode == "symbols" then
		prefix, zero_prefix, symbols_text, zero_symbols_text, has_nul_overlay = format_symbol_row(st, row_idx, bpr)
	else
		prefix, zero_prefix, symbols_text, zero_symbols_text, has_nul_overlay = format_hex_row(st, row_idx, 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),
		complete = is_row_fully_cached(st, row_idx, bpr),
		cache_generation = st.cache_generation,
	}
	row_cache[row_idx] = 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 the active session mode
	local st = get_doc_state(self.doc)
	st.mode = get_session_mode()
	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 = 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 = (min_row - 1) * bpr
		local end_off = math.min(n - 1, (max_row * bpr - 1))
		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 cached_row = get_cached_formatted_row(st, font, row, 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 = 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_top_row = math.max(1, rows - visible_rows + 1)
	local max_scroll = get_binary_max_scroll(self)
	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,
	}
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

-- Scroll by pixel delta then snap to nearest row boundary
local function scroll_by(self, delta)
	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
	core.redraw = true
end

-- Scroll to normalized position 0..1 then snap to rows
local function scroll_to_ratio(self, t)
	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.scroll.to.y = next_y
	core.redraw = true
end

-- Preserve top byte anchor when toggling between hex and symbol modes
local function sync_scroll_between_modes(self, from_mode, to_mode, anchor_offset)
	-- Convert a byte anchor to the destination mode row model

	if not (self and self.doc and is_binary_doc(self.doc)) then
		return
	end
	local st = get_doc_state(self.doc)
	local font = get_view_font()
	local lh = math.max(1, get_line_height())
	local top_byte_offset = tonumber(anchor_offset)

	if top_byte_offset == nil then
		-- Fallback when toggled before first draw pass for this doc
		local cur_y = self.scroll.y or 0
		st.mode = from_mode
		local old_bpr = select(1, get_row_metrics(self, st, font))
		local old_top_row = math.max(0, math.floor(cur_y / lh))
		top_byte_offset = old_top_row * math.max(1, old_bpr)
	end

	local n = st.file_size or 0

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

	st.mode = to_mode
	local to_bpr = select(1, get_row_metrics(self, st, font))
	to_bpr = math.max(1, to_bpr)
	local row_index = math.floor(top_byte_offset / to_bpr)

	-- Re-evaluate limits in destination mode, then position to row index
	local l = get_binary_scroll_layout(self)
	local max_row_index = math.max(0, l.max_top_row - 1)
	row_index = common.clamp(row_index, 0, max_row_index)
	local new_y = common.clamp(row_index * lh, 0, l.max_scroll)
	self.scroll.to.y = new_y
	self.scroll.y = new_y
end

-- Compute top-left visible byte offset for the given mode
local function get_current_top_byte_offset(self, mode)
	-- Read top byte from target scroll position, not interpolated current y

	if not (self and self.doc and is_binary_doc(self.doc)) then
		return 0
	end
	local st = get_doc_state(self.doc)
	local font = get_view_font()
	local lh = math.max(1, get_line_height())
	-- Use rendered position to avoid stale target-y anchors after mode/layout changes
	local y = self.scroll.y or 0
	local top_row0 = math.max(0, math.floor(y / lh))

	st.mode = mode
	local bpr = select(1, get_row_metrics(self, st, font))
	return top_row0 * math.max(1, bpr)
end

-- Initialize BinaryViewer as a doc-bound binary surface
function BinaryViewer:new(doc)
	local st = get_doc_state(doc)
	st.mode = get_session_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
	self.uses_tokenizer_draw = false
	self.uses_custom_draw = true
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

-- 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
function BinaryViewer:get_scrollable_size()
	local st = get_doc_state(self.doc)
	local _, rows = get_row_metrics(self, st, get_view_font())
	local content_h = get_binary_top_pad(self) + rows * get_line_height()
	return content_h
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

-- Clamp horizontal scroll and snap vertical target to whole rows
function BinaryViewer:update()
	BinaryViewer.super.update(self)
	local max_scroll = get_binary_max_scroll(self)
	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
	if config.binary_viewer_smooth_scroll == false then
		self.scroll.y = target_y
	else
		self.scroll.y = common.clamp(self.scroll.y or 0, 0, max_scroll)
	end
	self.scroll.to.x = 0
	self.scroll.x = 0
	self.mouse_cursor = "arrow"
end

-- Draw binary rows/rulers using native BinaryViewer render path
function BinaryViewer:draw()
	self:draw_background(style.background)
	draw_binary_doc(self)
	self:draw_scrollbar()
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

return BinaryViewer
