-- lx-bundle:true

--- Local image loading and drawing for rendered Markdown
-- @module plugins.mdpreview.images

local image_support = require("core.image.support")
local markdown_design = require("plugins.mdpreview.design")
local markdown_escapes = require("plugins.mdpreview.escapes")

local images = {}

-- Keep file polling beside the refresh behavior it controls
local REFRESH_INTERVAL = 1.0

--- Find one unescaped closing square bracket
-- @param text string Markdown source
-- @param position number first byte inside the brackets
-- @return number|nil closing_position closing bracket byte
local function find_closing_square(text, position)
	while position <= #text do
		local character = text:sub(position, position)
		local escaped_next_position

		if character == "\\" then
			local _, next_position = markdown_escapes.read(text, position)
			escaped_next_position = next_position
		end

		if escaped_next_position then
			position = escaped_next_position
		elseif character == "]" then
			return position
		else
			position = position + 1
		end
	end

	return nil
end

--- Read balanced parenthesized Markdown content
-- @param text string Markdown source
-- @param open_position number opening parenthesis byte
-- @return string|nil content inner content
-- @return number|nil next_position first byte after the closing parenthesis
local function read_parenthesized(text, open_position)
	if text:sub(open_position, open_position) ~= "(" then
		return nil
	end

	local depth = 1
	local position = open_position + 1
	local quote
	local angle = false

	while position <= #text do
		local character = text:sub(position, position)
		local escaped_next_position

		if character == "\\" then
			local _, next_position = markdown_escapes.read(text, position)
			escaped_next_position = next_position
		end

		if escaped_next_position then
			position = escaped_next_position
		elseif angle then
			angle = character ~= ">"
			position = position + 1
		elseif quote then
			if character == quote then
				quote = nil
			end
			position = position + 1
		elseif character == "<" then
			angle = true
			position = position + 1
		elseif (character == "\"" or character == "'")
			and text:sub(position - 1, position - 1):match("%s")
		then
			quote = character
			position = position + 1
		elseif character == "(" then
			depth = depth + 1
			position = position + 1
		elseif character == ")" then
			depth = depth - 1

			if depth == 0 then
				return text:sub(open_position + 1, position - 1), position + 1
			end

			position = position + 1
		else
			position = position + 1
		end
	end

	return nil
end

--- Separate an optional quoted or parenthesized Markdown title
-- @param content string destination and optional title
-- @return string target raw destination
-- @return string|nil title optional image title
local function split_target_and_title(content)
	content = content:match("^%s*(.-)%s*$")
	local final_character = content:sub(-1)

	if final_character == "\"" or final_character == "'" then
		local position = #content - 1

		while position >= 1 do
			if content:sub(position, position) == final_character
				and not markdown_escapes.is_escaped(content, position)
				and content:sub(position - 1, position - 1):match("%s")
			then
				local target = content:sub(1, position - 1):match("^(.-)%s*$")
				local title = content:sub(position + 1, -2)

				return target, markdown_escapes.decode(title)
			end

			position = position - 1
		end
	elseif final_character == ")" then
		local depth = 1
		local position = #content - 1

		while position >= 1 do
			local character = content:sub(position, position)

			if character == ")" and not markdown_escapes.is_escaped(content, position) then
				depth = depth + 1
			elseif character == "(" and not markdown_escapes.is_escaped(content, position) then
				depth = depth - 1

				if depth == 0 and content:sub(position - 1, position - 1):match("%s") then
					local target = content:sub(1, position - 1):match("^(.-)%s*$")
					local title = content:sub(position + 1, -2)

					return target, markdown_escapes.decode(title)
				end
			end

			position = position - 1
		end
	end

	return content
end

--- Normalize one parsed Markdown destination
-- @param target string raw destination
-- @return string target unescaped destination without angle brackets
local function normalize_target(target)
	target = target:match("^%s*(.-)%s*$")

	if target:sub(1, 1) == "<" and target:sub(-1) == ">" then
		target = target:sub(2, -2)
	end

	return markdown_escapes.decode(target)
end

--- Read one Markdown image expression from a known position
-- @param text string Markdown source
-- @param position number image marker byte
-- @return string|nil alt_text visible alternative text
-- @return string|nil target raw image target
-- @return string|nil title optional image title
-- @return number|nil next_position first byte after the image
local function read_image(text, position)
	if text:sub(position, position + 1) ~= "![" then
		return nil
	end

	local label_end = find_closing_square(text, position + 2)
	if not label_end or text:sub(label_end + 1, label_end + 1) ~= "(" then
		return nil
	end

	local content, next_position = read_parenthesized(text, label_end + 1)
	if not content then
		return nil
	end

	local target, title = split_target_and_title(content)
	target = normalize_target(target)

	if target == "" then
		return nil
	end

	local alt_text = markdown_escapes.decode(text:sub(position + 2, label_end - 1))

	return alt_text, target, title, next_position
end

--- Read one standalone Markdown image, optionally wrapped in a link
-- @param line string source line
-- @return string|nil alt_text visible alternative text
-- @return string|nil target raw image target
-- @return string|nil link_target optional image link target
-- @return string|nil title optional image title
function images.read_block(line)
	-- Pattern breakdown:
	-- '^%s*(.-)%s*$' removes only indentation around the complete block
	local text = line:match("^%s*(.-)%s*$")
	local image_position = text:sub(1, 1) == "[" and 2 or 1
	local alt_text, target, title, next_position = read_image(text, image_position)

	if not alt_text then
		return nil
	end

	if image_position == 1 then
		if next_position <= #text then
			return nil
		end

		return alt_text, target, nil, title
	end

	if text:sub(next_position, next_position + 1) ~= "](" then
		return nil
	end

	local link_content, link_end = read_parenthesized(text, next_position + 1)
	if not link_content or link_end <= #text then
		return nil
	end

	local link_target = normalize_target(split_target_and_title(link_content))

	return alt_text, target, link_target ~= "" and link_target or nil, title
end

--- Resolve one local image target beside its Markdown document
-- @param filename string|nil Markdown filename
-- @param target string raw image target
-- @return string|nil path absolute local path
-- @return string|nil reason fallback reason
function images.resolve_target(filename, target)
	local lower_target = target:lower()

	-- Remote images stay inert so opening a preview never makes a network request
	if lower_target:match("^https?://") then
		return nil, "remote"
	end

	if target:match("^[%a][%w+.-]*:") and not target:match("^%a:[/\\]") then
		return nil, "unsupported"
	end

	local path = target
	local absolute_path = path:match("^%a:[/\\]") or path:match("^[/\\]")

	if not absolute_path and type(filename) == "string" then
		local normalized_filename = (system.absolute_path(filename) or filename):gsub("\\", "/")
		local directory = normalized_filename:match("^(.*)/[^/]*$")

		if directory then
			path = system.join_path(directory, path)
		end
	end

	return system.absolute_path(path) or path
end

--- Reload one cached image from its current disk state
-- @param asset table cached image asset
local function reload_asset(asset)
	local info = system.get_file_info(asset.path)
	asset.modified = info and info.modified
	asset.size = info and info.size
	asset.image = nil
	asset.width = nil
	asset.height = nil
	asset.error = nil

	if not info or info.type ~= "file" then
		asset.error = "missing"
		return
	end

	asset.image, asset.error = canvas.load_image(asset.path)
	if asset.image then
		asset.width, asset.height = asset.image:get_size()
	end
end

--- Load and cache one local image without touching ordinary text views
-- @param view table Markdown preview
-- @param target string raw image target
-- @return table asset cached image result
local function load_asset(view, target)
	local path, reason = images.resolve_target(view.doc and view.doc.filename, target)

	if not path then
		return { error = reason }
	end

	local supported = image_support.is_supported(path)
	if not supported then
		return { path = path, error = "unsupported" }
	end

	view.markdown_image_cache = view.markdown_image_cache or {}
	local cached = view.markdown_image_cache[path]

	if cached then
		return cached
	end

	local asset = { path = path }
	view.markdown_image_cache[path] = asset
	view.markdown_image_paths = view.markdown_image_paths or {}
	view.markdown_image_paths[#view.markdown_image_paths + 1] = path
	reload_asset(asset)

	return asset
end

--- Build one image block at the current preview width and zoom
-- @param view table Markdown preview
-- @param alt_text string alternative text
-- @param target string raw image target
-- @param available_width number available document width
-- @param link_target string|nil optional image link target
-- @param title string|nil optional hover title
-- @return table block drawable image block
function images.build_block(view, alt_text, target, available_width, link_target, title)
	local asset = load_asset(view, target)
	local logical_fallback_height = markdown_design.images.fallback_height * (SCALE or 1)

	if not asset.image or not asset.width or asset.width <= 0 or not asset.height or asset.height <= 0 then
		local fallback_text = alt_text ~= "" and alt_text or target
		local fallback_detail = asset.error == "remote"
			and "Image: " .. target
			or nil
		local fallback_name_font = view:get_run_font(nil, {
			options = { bold = true },
			code = false,
		})
		local fallback_font_height = view.preview_fonts.body:get_height()
		local fallback_name_height = fallback_name_font:get_height()
		local fallback_padding_y =
			markdown_design.images.placeholder_padding_vertical * (SCALE or 1)
		local required_text_height = fallback_name_height
			+ (fallback_detail
				and fallback_font_height * markdown_design.typography.line_height_scale
				or 0)
			+ fallback_padding_y * 2

		return {
			asset = asset,
			height = math.max(logical_fallback_height, required_text_height),
			width = available_width,
			fallback_detail = fallback_detail,
			fallback_name_font = fallback_name_font,
			fallback_text = fallback_text,
			link_target = link_target,
			title = title,
		}
	end

	local zoomed_width = asset.width * view.zoom_multiplier
	local display_width = math.min(available_width, zoomed_width)
	local display_height = display_width * asset.height / asset.width

	return {
		asset = asset,
		height = math.max(1, display_height),
		width = math.max(1, display_width),
		link_target = link_target,
		title = title,
	}
end

--- Refresh changed local images at a low fixed rate
-- @param view table Markdown preview
-- @return boolean changed true when layout dimensions may have changed
function images.refresh_changed(view)
	local now = system.get_time()

	if now < (view.markdown_image_refresh_at or 0) then
		return false
	end
	view.markdown_image_refresh_at = now + REFRESH_INTERVAL

	local paths = view.markdown_image_paths or {}
	if #paths == 0 then
		return false
	end

	-- Check one cached file per interval so image-heavy documents keep stable frames
	local cursor = (view.markdown_image_refresh_cursor or 0) % #paths + 1
	view.markdown_image_refresh_cursor = cursor
	local asset = view.markdown_image_cache[paths[cursor]]
	local info = system.get_file_info(asset.path)
	local modified = info and info.modified
	local size = info and info.size

	if modified == asset.modified and size == asset.size then
		return false
	end

	reload_asset(asset)

	return true
end

--- Return the ordinary gap around a standalone image block
-- @return number gap scaled image gap
function images.get_block_gap()
	return markdown_design.images.block_gap * (SCALE or 1)
end

--- Draw only the visible slice of one image block
-- @param block table prepared image block
-- @param x number block left edge
-- @param y number block top edge
-- @param viewport_top number visible pane top
-- @param viewport_bottom number visible pane bottom
-- @param fallback_font userdata body font
function images.draw_block(block, x, y, viewport_top, viewport_bottom, fallback_font)
	local asset = block.asset

	if not asset.image then
		local line_width = math.max(
			1,
			markdown_design.images.placeholder_border_width * (SCALE or 1)
		)
		local border_color = markdown_design.get_image_placeholder_border_color()
		renderer.draw_rect(
			x,
			y,
			block.width,
			block.height,
			markdown_design.get_image_placeholder_background_color()
		)
		renderer.draw_rect(x, y, block.width, line_width, border_color)
		renderer.draw_rect(x, y + block.height - line_width, block.width, line_width, border_color)
		local name_font = block.fallback_name_font or fallback_font
		local name_height = name_font:get_height()
		local line_height = fallback_font:get_height() * markdown_design.typography.line_height_scale
		local text_height = name_height + (block.fallback_detail and line_height or 0)
		local text_y = y + (block.height - text_height) / 2
		local text_x = x + markdown_design.get_image_placeholder_padding_horizontal()

		renderer.draw_text(
			name_font,
			block.fallback_text,
			text_x,
			text_y,
			markdown_design.get_image_placeholder_text_color()
		)
		if block.fallback_detail then
			renderer.draw_text(
				fallback_font,
				block.fallback_detail,
				text_x,
				text_y + line_height,
				markdown_design.get_image_placeholder_text_color()
			)
		end
		return
	end

	local visible_top = math.max(y, viewport_top)
	local visible_bottom = math.min(y + block.height, viewport_bottom)

	if visible_bottom <= visible_top then
		return
	end

	local source_y = math.max(0, math.floor((visible_top - y) * asset.height / block.height))
	local source_bottom = math.min(asset.height, math.ceil((visible_bottom - y) * asset.height / block.height))
	local source_height = math.max(1, source_bottom - source_y)
	local dest_y = y + source_y * block.height / asset.height
	local dest_height = source_height * block.height / asset.height

	-- Restrict scaling to the visible slice so tall images do not burden every frame
	renderer.draw_canvas_scaled(
		asset.image,
		0,
		source_y,
		asset.width,
		source_height,
		x,
		dest_y,
		block.width,
		dest_height
	)
end

return images
