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

--- Find file references described by the active format
-- @module core.file.reference

local file_reference = {}

local string_byte = string.byte
local string_char = string.char
local string_find = string.find
local string_match = string.match
local table_insert = table.insert

local COLON = string_byte(":")
local COMMA = string_byte(",")
local FORWARD_SLASH = string_byte("/")
local BACK_SLASH = string_byte("\\")
local UPPER_A = string_byte("A")
local UPPER_Z = string_byte("Z")
local LOWER_A = string_byte("a")
local LOWER_Z = string_byte("z")
local ZERO = string_byte("0")
local NINE = string_byte("9")
local UNDERSCORE = string_byte("_")

-- Characters scanning may cross in ordinary file references
local REFERENCE_CHARACTERS = {}

for byte = string_byte("0"), string_byte("9") do
	REFERENCE_CHARACTERS[byte] = true
end

for byte = string_byte("A"), string_byte("Z") do
	REFERENCE_CHARACTERS[byte] = true
end

for byte = string_byte("a"), string_byte("z") do
	REFERENCE_CHARACTERS[byte] = true
end

-- Keep every byte of Unicode filenames inside the same scanned reference
for byte = 128, 255 do
	REFERENCE_CHARACTERS[byte] = true
end

local reference_punctuation = "._-+~@%&=/\\:"
for index = 1, #reference_punctuation do
	REFERENCE_CHARACTERS[string_byte(reference_punctuation, index)] = true
end

-- Standard locations add commas; their other characters already belong to ordinary references
local LOCATION_CHARACTERS = {}

for byte = string_byte("0"), string_byte("9") do
	LOCATION_CHARACTERS[byte] = true
end

local location_punctuation = "@,:-"
for index = 1, #location_punctuation do
	LOCATION_CHARACTERS[string_byte(location_punctuation, index)] = true
end

local reference_characters_with_locations = {}

for byte in pairs(REFERENCE_CHARACTERS) do
	reference_characters_with_locations[byte] = true
end

for byte in pairs(LOCATION_CHARACTERS) do
	reference_characters_with_locations[byte] = true
end

-- Format rules are stable, so custom character patterns need compiling only once
local rule_character_cache = setmetatable({}, { __mode = "k" })

-- Explicit opening may try one path-shaped candidate before filesystem validation
local EXPLICIT_PATH_RULE = { locations = true }

--- Return whether one rule enables standard location parsing
-- @param rule table one format-owned reference rule
-- @return boolean enabled
local function locations_enabled(rule)
	return rule.locations == true or rule.locations == "required"
end

--- Return whether one rule has a complete literal enclosure
-- @param rule table one format-owned reference rule
-- @return boolean paired
local function has_pair(rule)
	return type(rule.open) == "string" and rule.open ~= ""
		and type(rule.close) == "string" and rule.close ~= ""
end

--- Return whether one rule has enough context for confirmed references
-- Naked scanning belongs to later resolution-backed commands
-- @param rule table one format-owned reference rule
-- @return boolean identified
local function is_identified_rule(rule)
	if rule.open ~= nil or rule.close ~= nil then
		return has_pair(rule)
	end

	return type(rule.prefix) == "string" and rule.prefix ~= ""
		or rule.locations == "required"
end

--- Build fast character lookups for one format rule
-- Pattern matching happens here once, never inside a scan loop
-- @param rule table one format-owned reference rule
-- @return table scan_characters characters scanning may cross
-- @return table path_characters characters accepted in the raw path
local function get_rule_characters(rule)
	local chars = type(rule.chars) == "string" and rule.chars or nil
	local locations = locations_enabled(rule)
	local cached = rule_character_cache[rule]

	if cached and cached.chars == chars and cached.locations == locations then
		return cached.scan, cached.path
	end

	if not chars then
		local scan = locations and reference_characters_with_locations or REFERENCE_CHARACTERS
		cached = { locations = locations, scan = scan, path = REFERENCE_CHARACTERS }
		rule_character_cache[rule] = cached

		return cached.scan, cached.path
	end

	local pattern = "^" .. chars .. "$"
	local path_characters = {}
	local scan_characters = {}

	for byte = 0, 127 do
		if string_match(string_char(byte), pattern) then
			path_characters[byte] = true
			scan_characters[byte] = true
		end
	end

	for byte = 128, 255 do
		path_characters[byte] = true
		scan_characters[byte] = true
	end

	-- Drive, virtual-path, and location separators are owned by the shared parser
	scan_characters[COLON] = true
	if locations then
		for byte in pairs(LOCATION_CHARACTERS) do
			scan_characters[byte] = true
		end
	end

	cached = {
		chars = chars,
		locations = locations,
		scan = scan_characters,
		path = path_characters,
	}
	rule_character_cache[rule] = cached

	return cached.scan, cached.path
end

--- Separate raw path text from a location suffix accepted by the native parser
-- @param text string complete reference text
-- @param has_locations boolean true when the native parser found locations
-- @return string path_text
local function get_path_text(text, has_locations)
	if not has_locations then
		return text
	end

	local separator_index
	local index = #text

	local byte = string_byte(text, index)
	while index > 0 and LOCATION_CHARACTERS[byte] do
		if byte == COLON then
			separator_index = index
		end
		index = index - 1
		byte = string_byte(text, index)
	end

	return separator_index and text:sub(1, separator_index - 1) or text
end

--- Return whether a path follows one rule's characters and structural colons
-- Colons are limited to Windows drives and virtual paths
-- @param path string raw path text
-- @param rule table one format-owned reference rule
-- @param paired boolean true when exact enclosure provides the path boundary
-- @return boolean valid
local function is_valid_path(path, rule, paired)
	if path == "" then
		return false
	end

	local _, path_characters = get_rule_characters(rule)
	local check_characters = not paired or type(rule.chars) == "string"
	local has_name_character = false

	for index = 1, #path do
		local byte = string_byte(path, index)
		if byte >= 128 or byte >= UPPER_A and byte <= UPPER_Z
			or byte >= LOWER_A and byte <= LOWER_Z
			or byte >= ZERO and byte <= NINE or byte == UNDERSCORE
		then
			has_name_character = true
		end

		if byte == COLON then
			local first_byte = string_byte(path, 1)
			local next_byte = string_byte(path, index + 1)
			local drive_colon = index == 2
				and first_byte
				and (first_byte >= UPPER_A and first_byte <= UPPER_Z
					or first_byte >= LOWER_A and first_byte <= LOWER_Z)
				and (next_byte == FORWARD_SLASH or next_byte == BACK_SLASH)
			local virtual_colon = next_byte == FORWARD_SLASH

			if not drive_colon and not virtual_colon then
				return false
			end
		elseif check_characters and not path_characters[byte] then
			return false
		end
	end

	-- Punctuation-only runs such as C++ comment markers are not file references
	return has_name_character
end

--- Return whether optional source patterns accept one scanned path
-- Prefix is anchored immediately before the opener or unpaired path
-- @param line_text string complete logical line
-- @param context_col number opener or path start column
-- @param path string raw path text
-- @param rule table one format-owned reference rule
-- @return boolean accepted
local function matches_rule(line_text, context_col, path, rule)
	if type(rule.prefix) == "string"
		and not string_match(line_text:sub(1, context_col - 1), rule.prefix .. "$")
	then
		return false
	end

	return type(rule.match) ~= "string" or string_match(path, rule.match) ~= nil
end

--- Remove incomplete location separators from candidate edges
-- @param line_text string complete logical line
-- @param col1 number candidate start column
-- @param col2 number candidate end column
-- @param locations boolean true when locations are enabled
-- @return number col1 trimmed start column
-- @return number col2 trimmed end column
local function trim_candidate_edges(line_text, col1, col2, locations)
	if not locations then
		return col1, col2
	end

	while col1 < col2 do
		local byte = string_byte(line_text, col1)
		if byte ~= COLON and byte ~= COMMA then
			break
		end
		col1 = col1 + 1
	end

	while col2 > col1 do
		local byte = string_byte(line_text, col2 - 1)
		if byte ~= COLON and byte ~= COMMA then
			break
		end
		col2 = col2 - 1
	end

	return col1, col2
end

--- Move an unpaired candidate past a directly attached prefix
-- The common separated form matches its first boundary without walking the candidate
-- @param line_text string complete logical line
-- @param col1 number scanned candidate start column
-- @param col2 number scanned candidate end column
-- @param rule table prefixed format rule
-- @return number|nil col1 path start column
local function find_prefixed_start(line_text, col1, col2, rule)
	if type(rule.prefix) ~= "string" then
		return col1
	end

	local pattern = rule.prefix .. "()"
	local search_col = 1

	while search_col <= col2 do
		local match_col, match_end, candidate_col = string_find(line_text, pattern, search_col)
		if not match_col or match_col >= col2 then
			return nil
		end

		if candidate_col >= col1 and candidate_col < col2 then
			return candidate_col
		end

		-- Empty prefix patterns still move forward instead of repeating one match
		search_col = math.max(match_col + 1, match_end + 1)
	end

	return nil
end

--- Parse and qualify one complete candidate
-- @param line_text string complete logical line
-- @param col1 number candidate start column
-- @param col2 number candidate end column
-- @param context_col number opener or path start column
-- @param rule table one format-owned reference rule
-- @param rule_index number ordered rule index
-- @param paired boolean true when exact enclosure provided candidate edges
-- @return table|nil reference
local function parse_candidate(line_text, col1, col2, context_col, rule, rule_index, paired)
	if col1 >= col2 then
		return nil
	end

	local text = line_text:sub(col1, col2 - 1)
	local parsed = system.parse_file_reference(text, locations_enabled(rule))
	if not parsed then
		return nil
	end

	local path = get_path_text(text, #parsed.locations > 0)
	if rule.locations == "required" and #parsed.locations == 0
		or not is_valid_path(path, rule, paired)
		or not matches_rule(line_text, context_col, path, rule)
	then
		return nil
	end

	local reference = {
		col1 = col1,
		col2 = col2,
		text = text,
		path = path,
		filename = parsed.filename,
		virtual_path = parsed.virtual_path,
		locations = parsed.locations,
		rule = rule,
		rule_index = rule_index,
	}

	if type(rule.validate) == "function" and rule.validate(reference, line_text) ~= true then
		return nil
	end

	return reference
end

--- Scan one character-bounded rule around the cursor
-- @param line_text string complete logical line
-- @param cursor_col number cursor byte column
-- @param rule table one format-owned reference rule
-- @param rule_index number ordered rule index
-- @return table|nil reference
local function scan_character_rule(line_text, cursor_col, rule, rule_index)
	local line_length = #line_text
	local anchor = math.max(1, math.min(math.floor(tonumber(cursor_col) or 1), line_length + 1))
	local characters = get_rule_characters(rule)
	local anchor_byte = string_byte(line_text, anchor)

	if not anchor_byte or not characters[anchor_byte] then
		anchor = anchor - 1
		anchor_byte = string_byte(line_text, anchor)
		if anchor < 1 or not anchor_byte or not characters[anchor_byte] then
			return nil
		end
	end

	local col1 = anchor
	local col2 = anchor + 1

	while col1 > 1 and characters[string_byte(line_text, col1 - 1)] do
		col1 = col1 - 1
	end

	while col2 <= line_length and characters[string_byte(line_text, col2)] do
		col2 = col2 + 1
	end

	col1 = find_prefixed_start(line_text, col1, col2, rule)
	if not col1 then
		return nil
	end

	local trimmed_col1, trimmed_col2 = trim_candidate_edges(line_text, col1, col2, locations_enabled(rule))
	if anchor < trimmed_col1 or anchor >= trimmed_col2 then
		return nil
	end

	return parse_candidate(
		line_text,
		trimmed_col1,
		trimmed_col2,
		trimmed_col1,
		rule,
		rule_index,
		false
	)
end

--- Return the next non-nesting literal enclosure
-- @param line_text string complete logical line
-- @param rule table paired format rule
-- @param search_col number first possible opener column
-- @return number|nil open_col opener column
-- @return number|nil col1 content start column
-- @return number|nil col2 content end column
-- @return number|nil next_col column following the closer
local function find_next_pair(line_text, rule, search_col)
	local open_col = string_find(line_text, rule.open, search_col, true)
	if not open_col then
		return nil
	end

	local col1 = open_col + #rule.open
	local col2 = string_find(line_text, rule.close, col1, true)
	if not col2 then
		return nil
	end

	return open_col, col1, col2, col2 + #rule.close
end

--- Scan one paired rule around the cursor
-- @param line_text string complete logical line
-- @param cursor_col number cursor byte column
-- @param rule table paired format rule
-- @param rule_index number ordered rule index
-- @return table|nil reference
local function scan_pair_rule(line_text, cursor_col, rule, rule_index)
	local cursor = math.max(1, math.floor(tonumber(cursor_col) or 1))
	local search_col = 1

	while true do
		local open_col, col1, col2, next_col = find_next_pair(line_text, rule, search_col)
		if not open_col or open_col > cursor then
			return nil
		end

		local trimmed_col1, trimmed_col2 = trim_candidate_edges(
			line_text,
			col1,
			col2,
			locations_enabled(rule)
		)
		if cursor >= trimmed_col1 and cursor <= trimmed_col2 then
			local reference = parse_candidate(
				line_text,
				trimmed_col1,
				trimmed_col2,
				open_col,
				rule,
				rule_index,
				true
			)
			if reference then
				return reference
			end
		end

		search_col = next_col
	end
end

--- Insert one rule-qualified reference unless an earlier rule already owns its text
-- References stay ordered so overlap checks stop as soon as possible
-- @param references table accepted references
-- @param reference table new reference
local function insert_reference(references, reference)
	local insert_index = #references + 1

	for index = 1, #references do
		local current = references[index]
		if reference.col2 <= current.col1 then
			insert_index = index
			break
		end

		if reference.col1 < current.col2 then
			return
		end
	end

	table_insert(references, insert_index, reference)
end

--- Find every candidate accepted by one paired rule
-- @param line_text string complete logical line
-- @param rule table paired format rule
-- @param rule_index number ordered rule index
-- @param references table accepted references
local function scan_all_pair_rule(line_text, rule, rule_index, references)
	local search_col = 1

	while true do
		local open_col, col1, col2, next_col = find_next_pair(line_text, rule, search_col)
		if not open_col then
			return
		end

		col1, col2 = trim_candidate_edges(line_text, col1, col2, locations_enabled(rule))
		local reference = parse_candidate(line_text, col1, col2, open_col, rule, rule_index, true)
		if reference then
			insert_reference(references, reference)
		end

		search_col = next_col
	end
end

--- Find every candidate accepted by one character-bounded rule
-- @param line_text string complete logical line
-- @param rule table one format-owned reference rule
-- @param rule_index number ordered rule index
-- @param references table accepted references
local function scan_all_character_rule(line_text, rule, rule_index, references)
	local line_length = #line_text
	local characters = get_rule_characters(rule)
	local index = 1

	while index <= line_length do
		while index <= line_length and not characters[string_byte(line_text, index)] do
			index = index + 1
		end

		local col1 = index
		while index <= line_length and characters[string_byte(line_text, index)] do
			index = index + 1
		end

		if col1 < index then
			local col2
			col1 = find_prefixed_start(line_text, col1, index, rule)
			if col1 then
				col1, col2 = trim_candidate_edges(line_text, col1, index, locations_enabled(rule))
				local reference = parse_candidate(line_text, col1, col2, col1, rule, rule_index, false)
				if reference then
					insert_reference(references, reference)
				end
			end
		end
	end
end

--- Find one path-shaped candidate for an explicit resolution-backed action
-- This does not confirm a file reference; callers must require successful resolution
-- @param line_text string current logical line without LF
-- @param cursor_col number cursor byte column
-- @return table|nil reference
function file_reference.find_path(line_text, cursor_col)
	if type(line_text) ~= "string" then
		return nil
	end

	return scan_character_rule(line_text, cursor_col, EXPLICIT_PATH_RULE, 0)
end

--- Find the first format-owned file reference rule that accepts the cursor
-- Returned columns use the document's inclusive-start and exclusive-end convention
-- @param line_text string current logical line without LF
-- @param cursor_col number cursor byte column
-- @param rules table|nil ordered format-owned reference rules
-- @return table|nil reference
function file_reference.find(line_text, cursor_col, rules)
	if type(line_text) ~= "string" or type(rules) ~= "table" then
		return nil
	end

	for index = 1, #rules do
		local rule = rules[index]
		if type(rule) == "table" and is_identified_rule(rule) then
			local reference
			if has_pair(rule) then
				reference = scan_pair_rule(line_text, cursor_col, rule, index)
			else
				reference = scan_character_rule(line_text, cursor_col, rule, index)
			end
			if reference then
				return reference
			end
		end
	end

	return nil
end

--- Find every non-overlapping file reference on one logical line
-- Earlier format rules own overlapping text before later fallback rules
-- @param line_text string current logical line without LF
-- @param rules table|nil ordered format-owned reference rules
-- @return table references ordered references
function file_reference.find_all(line_text, rules)
	local references = {}
	if type(line_text) ~= "string" or type(rules) ~= "table" then
		return references
	end

	for index = 1, #rules do
		local rule = rules[index]
		if type(rule) == "table" and is_identified_rule(rule) then
			if has_pair(rule) then
				scan_all_pair_rule(line_text, rule, index, references)
			else
				scan_all_character_rule(line_text, rule, index, references)
			end
		end
	end

	return references
end

return file_reference
