-- lx-bundle:true

local core = require("core")
local config = require("core.config")
local tokenizer = require("core.syntax.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 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_thread(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,
	}
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 = {}
	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)
	for line in pairs(self.checkpoints) do
		if line >= idx then
			self.checkpoints[line] = nil
		end
	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
	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 interval = get_checkpoint_interval()
	local line = target - ((target - 1) % interval)
	while line > 1 do
		local encoded = self.checkpoints[line]
		if encoded ~= nil then
			return line, (encoded == CHECKPOINT_NIL_STATE) and nil or encoded
		end
		line = line - interval
	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
	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 priority scanner from visible line minus backtrack
-- Keep this bounded to the visible focus span so a small viewport request
-- does not repeatedly re-arm replay all the way to the end of the document
function Highlighter:rebase_priority_line(idx)
	idx = math.max(1, math.min(idx, #self.doc.lines))
	local start_line = math.max(1, idx - get_priority_backtrack_lines())
	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
	end
	self.priority_cursor_line = start_line
	self.priority_state = start_state
	self.priority_target_line = self.priority_focus_max
	self.priority_focus_min = idx
	self.priority_focus_max = math.min(#self.doc.lines, idx + PRIORITY_FOCUS_SPAN)
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 }
		res.state = nil
		return res
	end
	res.tokens, res.state = tokenizer.tokenize(self.doc.syntax, res.text, state, next_text)
	-- 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

-- 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
