-- lx-bundle:true

local core = require("core")
local config = require("core.config")
local tokenizer = require("core.format.tokenizer")
local Object = require("core.object")

local Highlighter = Object:extend()
local NoopHighlighter = Object:extend()

-- Encodes nil syntax state in checkpoint table values
local CHECKPOINT_NIL_STATE = false
-- Cap accumulated syntax worker time credit to avoid bursty catch-up spikes
local WORKER_CREDIT_CAP_MULTIPLIER = 4
-- Visible-line request span treated as same viewport before rebasing scanner
local PRIORITY_FOCUS_SPAN = 160

-- Return normalized checkpoint interval in lines
local function get_checkpoint_interval()
	local n = math.floor(tonumber(config.syntax_state_checkpoint_interval) or 200)
	return math.max(1, n)
end

-- Return the first sorted position whose line is not below 'target'
local function find_checkpoint_position(lines, target)
	local low = 1
	local high = #lines + 1

	while low < high do
		local middle = math.floor((low + high) / 2)

		if lines[middle] and lines[middle] < target then
			low = middle + 1
		else
			high = middle
		end
	end

	return low
end

-- Return syntax worker time budget per frame in seconds
local function get_worker_budget_seconds()
	local ms = tonumber(config.syntax_worker_budget_ms) or 1.2
	ms = math.max(0.1, ms)
	return ms / 1000
end

-- Return hard line cap processed by syntax worker in one frame
local function get_worker_max_lines_per_frame()
	local n = math.floor(tonumber(config.syntax_worker_max_lines_per_frame) or 60)
	return math.max(1, n)
end

-- Return visible-priority syntax worker budget per frame in seconds
local function get_priority_worker_budget_seconds()
	local ms = tonumber(config.syntax_worker_priority_budget_ms) or tonumber(config.syntax_worker_budget_ms) or 1.2
	ms = math.max(0.1, ms)
	return ms / 1000
end

-- Return visible-priority hard line cap processed in one frame
local function get_priority_worker_max_lines_per_frame()
	local n = math.floor(
		tonumber(config.syntax_worker_priority_max_lines_per_frame)
			or tonumber(config.syntax_worker_max_lines_per_frame)
			or 160
	)
	return math.max(1, n)
end

-- Return fixed backtrack lines for visible-priority anchor start
local function get_priority_backtrack_lines()
	local n = math.floor(tonumber(config.syntax_worker_priority_backtrack_lines) or 5)
	return math.max(0, n)
end

-- Return max canonical replay distance requested ahead from first invalid line
local function get_linear_ahead_lines()
	local n = math.floor(
		tonumber(config.syntax_worker_linear_ahead_lines) or tonumber(config.syntax_state_checkpoint_interval) or 200
	)
	return math.max(1, n)
end

-- Return max lines allowed for synchronous checkpoint replay in draw path
local function get_sync_max_lines()
	local n = math.floor(tonumber(config.syntax_state_sync_max_lines) or 80)
	return math.max(0, n)
end

-- Return whether cached line is an exact token row for given text and init state
local function is_exact_line(line, text, init_state)
	return line and not line.provisional and line.text == text and line.init_state == init_state
end

local function record_syntax_render_perf(key, delta)
	if config.debug_syntax_render_perf ~= true then
		return
	end

	if core.debug and type(core.debug.record_syntax_render_perf) == "function" then
		core.debug.record_syntax_render_perf(key, delta)
	end
end

--- Highlighter

-- Initializes highlighter state and starts background incremental highlighting
function Highlighter:new(doc)
	self.doc = doc
	self:reset()

	-- init incremental syntax highlighting
	-- Background worker that tokenizes invalid lines up to requested range
	core.add_coroutine(function()
		while true do
			local did_work = false
			self.max_wanted_line = math.min(self.max_wanted_line, #self.doc.lines)
			local priority_budget_s = get_priority_worker_budget_seconds()
			local worker_budget_s = get_worker_budget_seconds()
			-- Time-credit budgeting makes runtime knobs proportional and observable
			self.priority_worker_credit_s = math.min(
				(self.priority_worker_credit_s or 0) + priority_budget_s,
				priority_budget_s * WORKER_CREDIT_CAP_MULTIPLIER
			)
			self.worker_credit_s =
				math.min((self.worker_credit_s or 0) + worker_budget_s, worker_budget_s * WORKER_CREDIT_CAP_MULTIPLIER)

				-- Visible-priority replay runs independently from canonical linear replay
				-- This only counts real tokenization as work so exact cached lines do not
				-- keep re-arming redraws forever after the viewport has already settled
				do
					local max_lines = get_priority_worker_max_lines_per_frame()
					local tokenized = 0
					local processed = 0
					while
						self.priority_cursor_line
						and self.priority_cursor_line <= self.priority_target_line
						and self.priority_worker_credit_s > 0
						and processed < max_lines
					do
						local t_line = system.get_time()
						local i = self.priority_cursor_line
						local state = self.priority_state
						local text = self.doc.lines[i]
						local line = self.lines[i]
						-- Provisional placeholders are never treated as exact token rows
						if not is_exact_line(line, text, state) then
							self.lines[i] = self:tokenize_line(i, state)
							line = self.lines[i]
							tokenized = tokenized + 1
						end
						self.priority_state = line and line.state
						self.priority_cursor_line = i + 1
						-- Clamp at zero to avoid multi-frame debt stalls after expensive lines
						self.priority_worker_credit_s =
							math.max(0, self.priority_worker_credit_s - (system.get_time() - t_line))
						processed = processed + 1
					end
					if tokenized > 0 then
						did_work = true
					end
				end

			-- Clear finished visible-priority replay request
			if self.priority_cursor_line and self.priority_cursor_line > self.priority_target_line then
				self.priority_cursor_line = nil
				self.priority_target_line = 0
				self.priority_state = nil
			end

				-- Canonical replay advances state from first invalid line forward
				do
					local max_lines = get_worker_max_lines_per_frame()
					local tokenized = 0
					local processed = 0
					while
						self.first_invalid_line <= self.max_wanted_line
						and self.worker_credit_s > 0
						and processed < max_lines
					do
						local t_line = system.get_time()
						local i = self.first_invalid_line
						local prev = (i > 1) and self.lines[i - 1] or nil
						local state = prev and prev.state
						local text = self.doc.lines[i]
						local line = self.lines[i]
						-- Provisional placeholders are never treated as exact token rows
						if not is_exact_line(line, text, state) then
							self.lines[i] = self:tokenize_line(i, state)
							tokenized = tokenized + 1
						end
						self.first_invalid_line = i + 1
						-- Clamp at zero to avoid random delayed catch-up after costly tokenization
						self.worker_credit_s = math.max(0, self.worker_credit_s - (system.get_time() - t_line))
						processed = processed + 1
					end
					if tokenized > 0 then
						did_work = true
					end
				end

				if did_work then
					record_syntax_render_perf("highlighter_worker_work_cycles")
					core.redraw = true
					coroutine.yield()
			else
				if self.first_invalid_line > self.max_wanted_line then
					self.max_wanted_line = 0
				end
				coroutine.yield(1 / config.fps)
			end
		end
	end, self)
end

--- No-Op Highlighter

--- Initialize a highlighter variant that does nothing
-- @param doc core.doc owning document
function NoopHighlighter:new(doc)
	self.doc = doc
end

--- Reset no-op highlighter state
function NoopHighlighter:reset()
end

--- Ignore invalidation when syntax highlighting is disabled
-- @param idx integer ignored line index
function NoopHighlighter:invalidate(idx)
end

--- Ignore range tokenization when syntax highlighting is disabled
-- @param line1 integer ignored start line
-- @param line2 integer ignored end line
-- @param state any ignored tokenizer state
function NoopHighlighter:tokenize_range(line1, line2, state)
end

--- Return a plain unhighlighted token row for one line
-- @param idx integer line index
-- @return table line token row
function NoopHighlighter:get_line(idx)
	local text = self.doc.lines[idx] or ""
	return {
		provisional = false,
		init_state = nil,
		text = text,
		tokens = { "normal", text },
		state = nil,
		line_bg = nil,
	}
end

--- Return no syntax-owned row background when highlighting is disabled
-- @param idx integer ignored line index
-- @return nil
function NoopHighlighter:get_line_bg(idx)
	return nil
end

--- Iterate plain text tokens for one line when syntax highlighting is disabled
-- @param idx integer line index
-- @return function token iterator
function NoopHighlighter:each_token(idx)
	return tokenizer.each_token(self:get_line(idx).tokens)
end

-- Resets cached token lines and invalidation bounds
function Highlighter:reset()
	self.lines = {}
	-- Sparse map of line -> encoded init state at line start for checkpoint resume
	self.checkpoints = {}
	-- Sorted checkpoint lines make far-file lookup independent of document length
	self.checkpoint_lines = {}
	self.first_invalid_line = 1
	self.max_wanted_line = 0
	-- Visible-priority replay cursor for immediate viewport exact highlighting
	self.priority_cursor_line = nil
	-- Highest requested line in current visible-priority replay window
	self.priority_target_line = 0
	-- Syntax state at start of 'priority_cursor_line'
	self.priority_state = nil
	-- Tracks current viewport focus bounds for jump-triggered priority rebase
	self.priority_focus_min = 0
	self.priority_focus_max = 0
	-- Accumulated time credit for canonical background replay
	self.worker_credit_s = 0
	-- Accumulated time credit for visible-priority replay
	self.priority_worker_credit_s = 0
end

-- Remove checkpoint entries at and after 'idx'
function Highlighter:drop_checkpoints_from(idx)
	local first = find_checkpoint_position(self.checkpoint_lines, idx)

	for position = #self.checkpoint_lines, first, -1 do
		local line = self.checkpoint_lines[position]

		self.checkpoints[line] = nil
		self.checkpoint_lines[position] = nil
	end
end

-- Store checkpoint state for 'line' when it falls on checkpoint boundary
function Highlighter:store_checkpoint(line, state)
	if line <= 1 then
		return
	end
	local interval = get_checkpoint_interval()
	if ((line - 1) % interval) ~= 0 then
		return
	end

	if self.checkpoints[line] == nil then
		local position = find_checkpoint_position(self.checkpoint_lines, line)

		table.insert(self.checkpoint_lines, position, line)
	end

	self.checkpoints[line] = (state == nil) and CHECKPOINT_NIL_STATE or state
end

-- Return nearest checkpoint line and decoded state for 'target'
function Highlighter:get_nearest_checkpoint(target)
	local position = find_checkpoint_position(self.checkpoint_lines, target + 1) - 1
	local line = self.checkpoint_lines[position]

	if line then
		local encoded = self.checkpoints[line]

		return line, (encoded == CHECKPOINT_NIL_STATE) and nil or encoded
	end

	return nil, nil
end

-- Tokenize a line span sequentially starting from provided init state
function Highlighter:tokenize_range(line1, line2, state)
	for i = line1, line2 do
		local line = self.lines[i]
		-- Provisional placeholders are never treated as exact token rows
		if not (line and not line.provisional and line.text == self.doc.lines[i] and line.init_state == state) then
			line = self:tokenize_line(i, state)
			self.lines[i] = line
		end
		state = line.state
	end
end

-- Build non-blocking placeholder line for draw path responsiveness
function Highlighter:tokenize_line_provisional(idx, state_hint)
	local res = {}
	-- Marker used to force later exact retokenization in background loop
	res.provisional = true
	res.init_state = state_hint
	res.text = self.doc.lines[idx]
	-- Placeholder keeps UI snappy by skipping synchronous tokenizer work
	res.tokens = { "normal", res.text }
	res.state = state_hint
	-- Wait for exact state before deciding whether this is a fence or content row
	res.line_bg = nil
	return res
end

-- Marks highlighting cache as invalid starting from line 'idx'
function Highlighter:invalidate(idx)
	-- Edits can shift or change downstream state, so prune downstream checkpoints
	self:drop_checkpoints_from(idx)
	self.first_invalid_line = math.min(self.first_invalid_line, idx)
	self.max_wanted_line = math.min(self.max_wanted_line, #self.doc.lines)
	-- Invalidate visible-priority replay because downstream state may have changed
	self.priority_cursor_line = nil
	self.priority_target_line = 0
	self.priority_state = nil
	self.priority_focus_min = 0
	self.priority_focus_max = 0
	-- Reset worker credit so edits do not trigger delayed burst processing
	self.worker_credit_s = 0
	self.priority_worker_credit_s = 0
end

--- Rebase visible highlighting from a line with trustworthy syntax state
-- @param idx integer first requested visible line
function Highlighter:rebase_priority_line(idx)
	idx = math.max(1, math.min(idx, #self.doc.lines))
	local viewport_start = math.max(1, idx - get_priority_backtrack_lines())
	-- Carry multiline state forward from the normal worker instead of guessing near the viewport
	local start_line = math.min(viewport_start, self.first_invalid_line)
	local start_state = nil
	local prev_line = start_line - 1
	local prev = (prev_line >= 1) and self.lines[prev_line] or nil
	if prev and not prev.provisional and prev.text == self.doc.lines[prev_line] then
		start_state = prev.state
	elseif start_line > 1 then
		-- A missing boundary state must replay from the document start
		start_line = 1
	end
	self.priority_cursor_line = start_line
	self.priority_state = start_state
	self.priority_focus_min = idx
	self.priority_focus_max = math.min(#self.doc.lines, idx + PRIORITY_FOCUS_SPAN)
	self.priority_target_line = self.priority_focus_max
end

-- Request visible-priority exact replay for line 'idx'
function Highlighter:request_priority_line(idx)
	idx = math.max(1, math.min(idx, #self.doc.lines))
	if not self.priority_cursor_line then
		self:rebase_priority_line(idx)
		return
	end
	-- Clear old queue on jump/scroll to a different viewport region
	if idx < self.priority_focus_min or idx > self.priority_focus_max then
		self:rebase_priority_line(idx)
	end
end

-- Request bounded canonical replay so far jumps do not force immediate full-file catch-up
function Highlighter:request_linear_progress(idx)
	local doc_lines = #self.doc.lines
	local ahead = get_linear_ahead_lines()
	local bounded_target = math.min(doc_lines, self.first_invalid_line + ahead)
	local target = math.min(idx, bounded_target)
	self.max_wanted_line = math.max(self.max_wanted_line, target)
end

-- Try bounded synchronous replay to avoid visible provisional flash on nearby lines
function Highlighter:try_sync_replay_to(idx)
	local max_lines = get_sync_max_lines()
	if max_lines <= 0 then
		return false
	end

	local start_line = 1
	local start_state = nil
	local prev = self.lines[idx - 1]
	local prev_text = self.doc.lines[idx - 1]
	if prev and not prev.provisional and prev.text == prev_text then
		start_line = idx
		start_state = prev.state
	else
		local checkpoint_line, checkpoint_state = self:get_nearest_checkpoint(idx)
		if checkpoint_line and checkpoint_line <= idx then
			start_line = checkpoint_line
			start_state = checkpoint_state
		else
			start_line = 1
			start_state = nil
		end
	end

	if (idx - start_line + 1) > max_lines then
		return false
	end

	self:tokenize_range(start_line, idx, start_state)
	return true
end

-- Tokenizes one document line and returns cached token/state metadata
function Highlighter:tokenize_line(idx, state)
	local res = {}
	-- Exact tokenizer row (non-provisional cache entry)
	res.provisional = false
	res.init_state = state
	res.text = self.doc.lines[idx]
	local next_text = self.doc.lines[idx + 1]
	-- Guard against pathological long-line tokenization cost
	if #res.text > (config.long_line_syntax_line_length or 10000) then
		-- Fallback to plain text tokens for very long lines
		res.tokens = { "normal", res.text }
		-- Preserve incoming multiline context because the skipped line was not parsed
		res.state = state
		res.line_bg = tokenizer.extract_line_bg(self.doc.format, state)
		return res
	end
	res.tokens, res.state = tokenizer.tokenize(self.doc.format, res.text, state, next_text)
	-- A role on both sides means this is content, not an opening or closing fence
	local incoming_line_bg = tokenizer.extract_line_bg(self.doc.format, state)
	local outgoing_line_bg = tokenizer.extract_line_bg(self.doc.format, res.state)
	res.line_bg = incoming_line_bg == outgoing_line_bg and incoming_line_bg or nil
	-- Store state-at-line-start for future checkpoint-based resume
	self:store_checkpoint(idx + 1, res.state)
	return res
end

-- Returns highlighted data for a line, tokenizing on demand when stale
function Highlighter:get_line(idx)
	local line = self.lines[idx]
		if not line or line.provisional or line.text ~= self.doc.lines[idx] then
			-- Only stale lines request priority replay so exact cached lines do not
			-- continuously restart visible replay during ordinary redraws or cursor blink
			self:request_priority_line(idx)
		if self:try_sync_replay_to(idx) then
			line = self.lines[idx]
		else
			local prev = self.lines[idx - 1]
			if prev then
				-- Keep visible line retrieval non-blocking when sync replay is not viable
				line = self:tokenize_line_provisional(idx, prev.state)
				self.lines[idx] = line
			else
				-- Use nearest checkpoint only as provisional state seed when sync replay is skipped
				local checkpoint_line, checkpoint_state = self:get_nearest_checkpoint(idx)
				if checkpoint_line and checkpoint_line <= idx then
					line = self:tokenize_line_provisional(idx, checkpoint_state)
				else
					line = self:tokenize_line_provisional(idx, nil)
				end
				self.lines[idx] = line
			end
		end
	end
	self:request_linear_progress(idx)

	if line and line.provisional then
		record_syntax_render_perf("highlighter_get_line_provisional")
	else
		record_syntax_render_perf("highlighter_get_line_exact")
	end

	return line
end

--- Return one cached syntax-owned row background role
-- Exact rows take the redraw fast path without restarting highlighting progress
-- @param idx integer line index
-- @return string|nil line_bg named background role
function Highlighter:get_line_bg(idx)
	local line = self.lines[idx]
	if line and not line.provisional and line.text == self.doc.lines[idx] then
		return line.line_bg
	end

	line = self:get_line(idx)

	return line and line.line_bg or nil
end

-- Iterates tokens for a given line index
function Highlighter:each_token(idx)
	return tokenizer.each_token(self:get_line(idx).tokens)
end

--- Create a document highlighter according to config
-- @param doc core.doc owning document
-- @return core.text.highlighter|table highlighter instance
function Highlighter.create(doc)
	if config.syntax_highlighting == false then
		return NoopHighlighter(doc)
	else
		return Highlighter(doc)
	end
end

return Highlighter
