-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

--- Resolve parsed file locations against one loaded document
-- @module core.file.location

local codepage = require("core.text.codepage")
local navigate = require("core.text.navigate")

local file_location = {}

--- Return the effective bases after specific overrides inherit the global base
-- @param options table|nil CLI location options
-- @return table options
local function effective_options(options)
	options = type(options) == "table" and options or {}
	local base = options.base == 0 and 0 or 1

	return {
		line_base = options.line_base == 0 and 0 or (options.line_base == 1 and 1 or base),
		column_base = options.column_base == 0 and 0 or (options.column_base == 1 and 1 or base),
		offset_base = options.offset_base == 1 and 1 or 0,
		column_unit = options.column_unit or "codepoint",
		offset_unit = options.offset_unit or "byte",
		range_end = options.range_end == "exclusive" and "exclusive" or "inclusive",
	}
end

--- Remove the canonical in-memory newline from one document row
-- @param line string document row
-- @return string content
local function line_content(line)
	if line:sub(-1) == "\n" then
		return line:sub(1, -2)
	end

	return line
end

--- Convert one base-aware coordinate to a zero-based unit offset
-- @param value integer parsed coordinate
-- @param base integer zero or one
-- @param label string error label
-- @return integer|nil offset
-- @return string|nil err
local function zero_based(value, base, label)
	if base == 1 and value == 0 then
		return nil, label .. " 0 is invalid with base 1"
	end

	return value - base
end

--- Return the encoded unit width of one decoded codepoint
-- @param codepoint_value integer Unicode codepoint
-- @param unit string requested column unit
-- @param doc table text document
-- @return integer width
local function codepoint_unit_width(codepoint_value, unit, doc)
	if unit == "codepoint" then
		return 1
	elseif unit == "utf16" then
		return codepoint_value > 0xFFFF and 2 or 1
	elseif unit == "byte" then
		if doc.file_encoding == "utf16le" or doc.file_encoding == "utf16be" then
			return codepoint_value > 0xFFFF and 4 or 2
		elseif doc.file_codepage then
			return 1
		end
	end

	return #utf8.char(codepoint_value)
end

--- Resolve one decoded-text column to an internal UTF-8 byte column
-- @param doc table text document
-- @param line integer internal line
-- @param value integer parsed column
-- @param options table effective location options
-- @param inclusive boolean include the codepoint at this coordinate
-- @return integer|nil column
-- @return string|nil err
local function resolve_decoded_column(doc, line, value, options, inclusive)
	local target, base_error = zero_based(value, options.column_base, "Column")
	if not target then
		return nil, base_error
	end

	local content = line_content(doc.lines[line])
	local consumed_units = 0

	for byte_column, codepoint_value in utf8.codes(content) do
		local unit_width = codepoint_unit_width(codepoint_value, options.column_unit, doc)
		if target == consumed_units then
			return inclusive and (byte_column + #utf8.char(codepoint_value)) or byte_column
		elseif target < consumed_units + unit_width then
			return nil, string.format(
				"Column %d falls inside one %s character",
				value,
				options.column_unit
			)
		end

		consumed_units = consumed_units + unit_width
	end

	if target == consumed_units and not inclusive then
		return #content + 1
	end

	return nil, string.format("Column out of range on line %d: %d", line, value)
end

--- Decode one UTF-8 source sequence with replacement behavior matching file loading
-- @param data string encoded source
-- @param index integer one-based byte index
-- @return integer codepoint_value
-- @return integer consumed
local function decode_utf8_at(data, index)
	local first = data:byte(index)
	if first <= 0x7F then
		return first, 1
	end

	local second = data:byte(index + 1)
	if first >= 0xC2 and first <= 0xDF and second and second >= 0x80 and second <= 0xBF then
		return (first - 0xC0) * 0x40 + (second - 0x80), 2
	end

	local third = data:byte(index + 2)
	if first >= 0xE0
		and first <= 0xEF
		and second and second >= 0x80 and second <= 0xBF
		and third and third >= 0x80 and third <= 0xBF
	then
		local value = (first - 0xE0) * 0x1000 + (second - 0x80) * 0x40 + (third - 0x80)
		if value >= 0x0800 and not (value >= 0xD800 and value <= 0xDFFF) then
			return value, 3
		end
	end

	local fourth = data:byte(index + 3)
	if first >= 0xF0
		and first <= 0xF4
		and second and second >= 0x80 and second <= 0xBF
		and third and third >= 0x80 and third <= 0xBF
		and fourth and fourth >= 0x80 and fourth <= 0xBF
	then
		local value = (first - 0xF0) * 0x40000
			+ (second - 0x80) * 0x1000
			+ (third - 0x80) * 0x40
			+ (fourth - 0x80)
		if value >= 0x10000 and value <= 0x10FFFF then
			return value, 4
		end
	end

	return 0xFFFD, 1
end

--- Read one UTF-16 code unit in the document's byte order
-- @param data string encoded source
-- @param index integer one-based byte index
-- @param little_endian boolean byte order
-- @return integer|nil unit
local function read_utf16_unit(data, index, little_endian)
	local first, second = data:byte(index, index + 1)
	if not second then
		return nil
	end

	return little_endian and (first + second * 0x100) or (first * 0x100 + second)
end

--- Decode one UTF-16 source sequence with replacement behavior matching file loading
-- @param data string encoded source
-- @param index integer one-based byte index
-- @param little_endian boolean byte order
-- @return integer codepoint_value
-- @return integer consumed
local function decode_utf16_at(data, index, little_endian)
	local first = read_utf16_unit(data, index, little_endian)
	if not first then
		return 0xFFFD, 1
	end

	if first >= 0xD800 and first <= 0xDBFF then
		local second = read_utf16_unit(data, index + 2, little_endian)
		if second and second >= 0xDC00 and second <= 0xDFFF then
			return 0x10000 + (first - 0xD800) * 0x400 + (second - 0xDC00), 4
		end
		return 0xFFFD, 2
	elseif first >= 0xDC00 and first <= 0xDFFF then
		return 0xFFFD, 2
	end

	return first, 2
end

--- Build a source decoder and report bytes skipped as its BOM
-- @param doc table text document
-- @param data string encoded source
-- @return function next_codepoint
-- @return integer bom_bytes
local function source_decoder(doc, data)
	local encoding = doc.file_encoding or "utf8"
	local index = 1
	local codepage_map

	if encoding == "utf16le" and data:sub(1, 2) == "\255\254" then
		index = 3
	elseif encoding == "utf16be" and data:sub(1, 2) == "\254\255" then
		index = 3
	elseif not doc.file_codepage
		and encoding == "utf8"
		and data:sub(1, 3) == "\239\187\191"
	then
		index = 4
	end

	local bom_bytes = index - 1
	if doc.file_codepage then
		codepage_map = codepage.load(doc.file_codepage)
	end

	--- Decode the next source codepoint and retain its raw byte boundaries
	-- @return table|nil event
	local function next_codepoint()
		if index > #data then
			return nil
		end

		local start_offset = index - 1
		local codepoint_value
		local consumed

		if encoding == "utf16le" or encoding == "utf16be" then
			codepoint_value, consumed = decode_utf16_at(data, index, encoding == "utf16le")
		elseif codepage_map then
			local mapped = codepage_map[data:byte(index)]
			codepoint_value = type(mapped) == "string" and utf8.codepoint(mapped) or 0x3F
			consumed = 1
		else
			codepoint_value, consumed = decode_utf8_at(data, index)
		end

		index = index + consumed

		return {
			codepoint = codepoint_value,
			start_offset = start_offset,
			end_offset = index - 1,
		}
	end

	return next_codepoint, bom_bytes
end

--- Group CRLF so its two source codepoints share one document line break
-- @param doc table text document
-- @param data string encoded source
-- @return function next_event
-- @return integer bom_bytes
local function source_events(doc, data)
	local next_codepoint, bom_bytes = source_decoder(doc, data)
	local pending

	--- Return the next character or logical line-ending event
	-- @return table|nil event
	local function next_event()
		local event = pending or next_codepoint()
		pending = nil

		if not event then
			return nil
		end

		if event.codepoint == 0x0D then
			local following = next_codepoint()
			if following and following.codepoint == 0x0A then
				event.kind = "eol"
				event.middle_offset = event.end_offset
				event.end_offset = following.end_offset
				event.codepoint_count = 2
				return event
			end
			pending = following
			event.kind = "eol"
			event.codepoint_count = 1
			return event
		elseif event.codepoint == 0x0A then
			event.kind = "eol"
			event.codepoint_count = 1
			return event
		end

		event.kind = "character"
		event.codepoint_count = 1
		return event
	end

	return next_event, bom_bytes
end

--- Check that a decoded source position exists unchanged in the loaded document
-- @param doc table text document
-- @param line integer internal line
-- @param column integer internal byte column
-- @return boolean valid
local function valid_document_position(doc, line, column)
	if line < 1 or line > #doc.lines then
		return false
	end

	return column >= 1 and column <= #line_content(doc.lines[line]) + 1
end

--- Resolve one source byte or decoded-codepoint offset
-- @param doc table text document
-- @param data string original encoded source
-- @param target integer zero-based requested offset
-- @param unit string 'byte' or 'codepoint'
-- @param inclusive boolean include the source unit at the coordinate
-- @return integer|nil line
-- @return integer|string|nil column_or_error
local function resolve_source_offset(doc, data, target, unit, inclusive)
	local next_event, bom_bytes = source_events(doc, data)
	local line = 1
	local column = 1
	local codepoint_offset = 0

	if unit == "byte" and bom_bytes > 0 and target < bom_bytes then
		-- A caret may sit at the file start, but a selected BOM byte has no text position
		if target == 0 and not inclusive then
			return 1, 1
		end

		return nil, "Offset points inside the file BOM"
	end

	while true do
		local event = next_event()
		if not event then
			local current_offset = unit == "byte" and #data or codepoint_offset
			if target == current_offset and not inclusive and valid_document_position(doc, line, column) then
				return line, column
			end
			return nil, string.format("Offset out of range: %d", target)
		end

		local event_offset = unit == "byte" and event.start_offset or codepoint_offset
		local event_width = unit == "byte"
			and (event.end_offset - event.start_offset)
			or event.codepoint_count

		if target == event_offset then
			if not inclusive then
				if valid_document_position(doc, line, column) then
					return line, column
				end

				return nil, "Decoded offset does not match the loaded document"
			end

			if event.kind == "eol" then
				if valid_document_position(doc, line + 1, 1) then
					return line + 1, 1
				end

				return nil, "Decoded offset does not match the loaded document"
			end

			local next_column = column + #utf8.char(event.codepoint)
			if valid_document_position(doc, line, next_column) then
				return line, next_column
			end

			return nil, "Decoded offset does not match the loaded document"
		elseif target > event_offset and target < event_offset + event_width then
			if event.kind == "eol" then
				return line + 1, 1
			end
			return nil, string.format("Offset %d falls inside one encoded character", target)
		end

		if event.kind == "eol" then
			line = line + 1
			column = 1
		else
			column = column + #utf8.char(event.codepoint)
		end
		codepoint_offset = codepoint_offset + event.codepoint_count
	end
end

--- Resolve an actual-file byte column using exact source line boundaries
-- @param doc table text document
-- @param data string original encoded source
-- @param target_line integer internal line
-- @param target integer zero-based byte column
-- @param inclusive boolean include the encoded character at this coordinate
-- @return integer|nil column
-- @return string|nil err
local function resolve_source_byte_column(doc, data, target_line, target, inclusive)
	local next_event, bom_bytes = source_events(doc, data)
	local line = 1
	local column = 1
	local line_start_offset = bom_bytes

	while true do
		local event = next_event()
		if not event then
			if line == target_line and target == #data - line_start_offset and not inclusive then
				return column
			end
			return nil, string.format("Column out of range on line %d: %d", target_line, target)
		end

		if line == target_line then
			local relative_start = event.start_offset - line_start_offset
			if target == relative_start then
				if inclusive and event.kind == "eol" then
					return nil, string.format("Column out of range on line %d: %d", target_line, target)
				elseif inclusive then
					return column + #utf8.char(event.codepoint)
				end
				return column
			elseif target > relative_start and target < event.end_offset - line_start_offset then
				return nil, string.format("Column %d falls inside one encoded character", target)
			elseif event.kind == "eol" then
				return nil, string.format("Column out of range on line %d: %d", target_line, target)
			end
		end

		if event.kind == "eol" then
			line = line + 1
			column = 1
			line_start_offset = event.end_offset
		else
			column = column + #utf8.char(event.codepoint)
		end
	end
end

--- Resolve one parsed text endpoint
-- @param doc table text document
-- @param source_data string|nil original encoded source
-- @param line_value integer parsed line
-- @param column_value integer|nil parsed column
-- @param options table effective options
-- @param inclusive boolean include the endpoint character
-- @return integer|nil line
-- @return integer|string|nil column_or_error
local function resolve_text_endpoint(doc, source_data, line_value, column_value, options, inclusive)
	local line, line_error = zero_based(line_value, options.line_base, "Line")
	if not line then
		return nil, line_error
	end
	line = line + 1

	if line < 1 or line > #doc.lines then
		return nil, string.format("Line out of range: %d", line_value)
	end

	if column_value == nil then
		return line, 1
	end

	if options.column_unit == "byte" then
		if type(source_data) ~= "string" then
			return nil, "Byte columns require the original file bytes"
		end

		local column_offset, column_error = zero_based(column_value, options.column_base, "Column")
		if not column_offset then
			return nil, column_error
		end

		local column, resolve_error = resolve_source_byte_column(
			doc,
			source_data,
			line,
			column_offset,
			inclusive
		)
		return column and line or nil, column or resolve_error
	end

	local column, column_error = resolve_decoded_column(doc, line, column_value, options, inclusive)
	return column and line or nil, column or column_error
end

--- Compare two internal document positions
-- @return integer order negative, zero, or positive
local function compare_positions(line1, column1, line2, column2)
	if line1 ~= line2 then
		return line1 - line2
	end

	return column1 - column2
end

--- Return the position after one complete document line
-- @param doc table text document
-- @param line integer internal line
-- @return integer end_line
-- @return integer end_column
local function after_line(doc, line)
	if line < #doc.lines then
		return line + 1, 1
	end

	return line, #line_content(doc.lines[line]) + 1
end

--- Resolve one text position or range into one selection snapshot
-- @param doc table text document
-- @param source_data string|nil original encoded source
-- @param location table parsed text location
-- @param options table effective options
-- @return table|nil selection
-- @return string|nil err
local function resolve_text_location(doc, source_data, location, options)
	local start_line, start_column = resolve_text_endpoint(
		doc,
		source_data,
		location.line,
		location.column,
		options,
		false
	)
	if not start_line then
		return nil, start_column
	end

	if location.end_line == nil then
		return { start_line, start_column }
	end

	local end_line, end_column = resolve_text_endpoint(
		doc,
		source_data,
		location.end_line,
		location.end_column,
		options,
		false
	)
	if not end_line then
		return nil, end_column
	elseif start_line > end_line then
		return nil, "Text range starts after it ends"
	end

	if location.end_column == nil then
		if options.range_end == "inclusive" then
			end_line, end_column = after_line(doc, end_line)
		end
	elseif options.range_end == "inclusive" then
		end_line, end_column = resolve_text_endpoint(
			doc,
			source_data,
			location.end_line,
			location.end_column,
			options,
			true
		)
		if not end_line then
			return nil, end_column
		end
	end

	if compare_positions(start_line, start_column, end_line, end_column) > 0 then
		return nil, "Text range starts after it ends"
	end

	return { start_line, start_column, end_line, end_column }
end

--- Resolve one parsed offset or offset range into one selection snapshot
-- @param doc table text document
-- @param source_data string|nil original encoded source
-- @param location table parsed offset location
-- @param options table effective options
-- @return table|nil selection
-- @return string|nil err
local function resolve_offset_location(doc, source_data, location, options)
	if type(source_data) ~= "string" then
		return nil, "Offsets require the original file bytes"
	end

	local start_offset, start_base_error = zero_based(location.offset, options.offset_base, "Offset")
	if not start_offset then
		return nil, start_base_error
	end

	local start_line, start_column = resolve_source_offset(
		doc,
		source_data,
		start_offset,
		options.offset_unit,
		false
	)
	if not start_line then
		return nil, start_column
	end

	if location.end_offset == nil then
		return { start_line, start_column }
	end

	local end_offset, end_base_error = zero_based(location.end_offset, options.offset_base, "Offset")
	if not end_offset then
		return nil, end_base_error
	elseif start_offset > end_offset then
		return nil, "Offset range starts after it ends"
	end

	local end_line, end_column = resolve_source_offset(
		doc,
		source_data,
		end_offset,
		options.offset_unit,
		options.range_end == "inclusive"
	)
	if not end_line then
		return nil, end_column
	end

	return { start_line, start_column, end_line, end_column }
end

--- Return whether resolving this request requires original encoded bytes
-- @param locations table parsed locations
-- @param options table|nil CLI location options
-- @return boolean required
function file_location.needs_source_data(locations, options)
	options = effective_options(options)

	for _, location in ipairs(locations or {}) do
		if location.type == "offset"
			or (location.type == "text"
				and options.column_unit == "byte"
				and (location.column ~= nil or location.end_column ~= nil))
		then
			return true
		end
	end

	return false
end

--- Resolve every parsed location into editor selection snapshots
-- @param doc table loaded text document
-- @param locations table parsed locations
-- @param options table|nil CLI location options
-- @param source_data string|nil original encoded bytes
-- @return table|nil selections
-- @return string|nil err
function file_location.resolve_text(doc, locations, options, source_data)
	if not (doc and type(doc.lines) == "table" and type(doc.set_selections) == "function") then
		return nil, "Locations require a text document"
	end

	options = effective_options(options)
	local selections = {}

	for index, location in ipairs(locations or {}) do
		local selection
		local resolve_error

		if location.type == "text" then
			selection, resolve_error = resolve_text_location(doc, source_data, location, options)
		elseif location.type == "offset" then
			selection, resolve_error = resolve_offset_location(doc, source_data, location, options)
		else
			resolve_error = "Unknown location type"
		end

		if not selection then
			return nil, string.format("Location %d: %s", index, tostring(resolve_error))
		end
		selections[#selections + 1] = selection
	end

	return selections
end

--- Resolve and apply locations to one text document view
-- @param doc table loaded document
-- @param view table|nil opened view
-- @param locations table parsed locations
-- @param options table|nil CLI location options
-- @param source_data string|nil original encoded bytes
-- @return boolean ok
-- @return string|nil err
function file_location.apply_text(doc, view, locations, options, source_data)
	if not locations or #locations == 0 then
		return true
	end

	local selections, resolve_error = file_location.resolve_text(doc, locations, options, source_data)
	if not selections then
		return false, resolve_error
	end

	local bookmarks = package.loaded["plugins.bookmarks"]
	if bookmarks and type(bookmarks.add_doc_lines) == "function" then
		local bookmark_lines = {}
		for _, selection in ipairs(selections) do
			bookmark_lines[#bookmark_lines + 1] = selection[1]
		end
		-- Session bookmarks make every requested location easy to revisit with F2
		bookmarks.add_doc_lines(doc, bookmark_lines)
	end

	doc:set_selections(selections, 1)
	local first = selections[1]
	if view and type(view.scroll_to_line) == "function" then
		-- Use same viewport position as Ctrl+G after startup layout is ready
		navigate.reveal_line_col(first[1], first[2], view, true)
	end

	return true
end

--- Return the size of one binary document without reading its complete contents
-- @param doc table binary document
-- @return integer|nil size
-- @return string|nil err
local function binary_size(doc)
	if type(doc.binary_source_data) == "string" then
		return #doc.binary_source_data
	end

	local info, info_error = system.get_file_info(doc.filename)
	if info and tonumber(info.size) then
		return math.floor(tonumber(info.size))
	end

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

--- Resolve byte locations for one binary document
-- @param doc table binary document
-- @param locations table parsed locations
-- @param options table|nil CLI location options
-- @return table|nil ranges zero-based byte ranges with exclusive ends
-- @return string|nil err
function file_location.resolve_binary(doc, locations, options)
	options = effective_options(options)
	if options.offset_unit ~= "byte" then
		return nil, "Binary documents only support byte offsets"
	end

	local size, size_error = binary_size(doc)
	if not size then
		return nil, size_error
	end

	local ranges = {}
	for index, location in ipairs(locations or {}) do
		if location.type ~= "offset" then
			return nil, string.format("Location %d: Binary documents require byte offsets", index)
		end

		local start_offset, start_error = zero_based(location.offset, options.offset_base, "Offset")
		if not start_offset then
			return nil, string.format("Location %d: %s", index, start_error)
		elseif start_offset > size then
			return nil, string.format("Location %d: Offset out of range: %d", index, location.offset)
		end

		local exclusive_end = start_offset
		if location.end_offset ~= nil then
			local end_offset
			local end_error
			end_offset, end_error = zero_based(location.end_offset, options.offset_base, "Offset")
			if not end_offset then
				return nil, string.format("Location %d: %s", index, end_error)
			elseif start_offset > end_offset then
				return nil, string.format("Location %d: Offset range starts after it ends", index)
			end
			exclusive_end = end_offset + (options.range_end == "inclusive" and 1 or 0)
		end

		if exclusive_end > size then
			return nil, string.format("Location %d: Offset out of range: %d", index, location.end_offset or location.offset)
		end

		ranges[#ranges + 1] = { start_offset, exclusive_end }
	end

	return ranges
end

--- Apply parsed locations to the matching text or binary view
-- @param doc table loaded document
-- @param view table|nil opened view
-- @param locations table parsed locations
-- @param options table|nil CLI location options
-- @param source_data string|nil original encoded bytes
-- @return boolean ok
-- @return string|nil err
function file_location.apply(doc, view, locations, options, source_data)
	if doc and doc.is_binary_placeholder == true then
		local ranges, range_error = file_location.resolve_binary(doc, locations, options)
		if not ranges then
			return false, range_error
		end

		if view then
			view._file_location_ranges = ranges
		end
		if view and type(view.scroll_to_byte_offset) == "function" then
			view:scroll_to_byte_offset(ranges[1][1])
		end

		return true
	end

	return file_location.apply_text(doc, view, locations, options, source_data)
end

return file_location
