-- 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 references beside their source document
-- @module core.file.reference_resolver

local file_reference_resolver = {}

-- TODO: Revisit project fallback after the real asynchronous project system exists
-- The resolver should consume a complete published index snapshot and never scan or wait for one itself
-- A caller may request a missing or stale snapshot, then retry only while its original source request remains valid
-- Shared requests should use one worker scan, stale requests should cancel, and ambiguous exact matches must never open by guess

--- Normalize one path for suffix comparisons inside the project index
-- @param path string file path
-- @return string normalized
local function normalize_lookup_path(path)
	local normalized = system.normalize_path(path):gsub("\\", "/"):gsub("^%./", "")

	return PATHSEP == "\\" and normalized:lower() or normalized
end

--- Return whether one path already names a filesystem root
-- @param path string file path
-- @return boolean absolute
local function is_absolute_path(path)
	if PATHSEP == "\\" then
		return path:match("^%a:[/\\]") ~= nil or path:match("^[/\\]") ~= nil
	end

	return path:sub(1, 1) == "/"
end

--- Copy one reference with its resolved physical and complete paths
-- @param reference table parsed file reference
-- @param filename string absolute physical filename
-- @return table resolved_reference
local function copy_resolved_reference(reference, filename)
	local resolved = {}

	for key, value in pairs(reference) do
		resolved[key] = value
	end

	resolved.filename = filename
	resolved.path = system.format_filename(filename, reference.virtual_path)

	return resolved
end

--- Add one filename and its generic slash-rooted fallback without duplicates
-- @param candidates table destination candidate list
-- @param seen table filenames already added
-- @param reference table original parsed reference
-- @param filename string candidate filename
local function add_reference_candidate(candidates, seen, reference, filename)
	if filename == "" or seen[filename] then
		return
	end

	local candidate = {}
	for key, value in pairs(reference) do
		candidate[key] = value
	end

	candidate.filename = filename
	candidate.path = system.format_filename(filename, reference.virtual_path)
	candidates[#candidates + 1] = candidate
	seen[filename] = true

	-- Text often contributes '/folder/file' as one piece of a larger expression
	local relative_filename = filename:gsub("^/+", "")
	if relative_filename ~= filename then
		add_reference_candidate(candidates, seen, reference, relative_filename)
	end
end

--- Return the original reference or format-provided filename alternatives
-- @param reference table parsed file reference
-- @param source_filename string|nil current document filename
-- @return table candidates
local function get_reference_candidates(reference, source_filename)
	local provide_candidates = reference.rule and reference.rule.candidates
	local filenames = type(provide_candidates) == "function"
		and provide_candidates(reference, source_filename)
		or { reference.filename }
	local candidates = {}
	local seen = {}
	if type(filenames) ~= "table" then
		return candidates
	end

	for _, filename in ipairs(filenames) do
		if type(filename) == "string" and filename ~= "" then
			add_reference_candidate(candidates, seen, reference, filename)
		end
	end

	return candidates
end

--- Record one filesystem candidate only when the caller requested details
-- @param attempts table|nil optional attempt list
-- @param filename string|nil physical candidate
-- @param found boolean candidate exists
-- @param place string readable search position kind
-- @param parent_levels number number of parent folders crossed
local function record_attempt(attempts, filename, found, place, parent_levels)
	if attempts then
		attempts[#attempts + 1] = {
			filename = filename,
			found = found,
			place = place,
			parent_levels = parent_levels,
		}
	end
end

--- Record one compact project-index result
-- @param attempts table|nil optional attempt list
-- @param reference_filename string requested relative filename
-- @param matches table existing exact suffix matches
-- @param indexed_count number indexed files considered
local function record_project_attempt(attempts, reference_filename, matches, indexed_count)
	if attempts then
		attempts[#attempts + 1] = {
			filename = matches[1] or reference_filename,
			found = #matches == 1,
			place = "project",
			matches = matches,
			match_count = #matches,
			indexed_count = indexed_count,
		}
	end
end

--- Return one existing candidate without applying its virtual path to disk access
-- @param reference table parsed file reference
-- @param directory string|nil directory for relative paths
-- @param attempts table|nil optional attempt list
-- @param place string readable search position kind
-- @param parent_levels number number of parent folders crossed
-- @return table|nil resolved_reference
local function resolve_from(reference, directory, attempts, place, parent_levels)
	local filename = system.absolute_path(reference.filename, directory)
	local found = filename ~= nil and system.is_file(filename) == true
	record_attempt(attempts, filename, found, place, parent_levels)

	if found then
		return copy_resolved_reference(reference, filename)
	end
end

--- Return whether one indexed project path ends with the complete requested path
-- @param candidate string indexed project filename
-- @param requested string requested relative filename
-- @return boolean matches
local function project_path_matches(candidate, requested)
	if candidate == requested then
		return true
	end

	return #candidate > #requested
		and candidate:sub(-#requested) == requested
		and candidate:sub(-#requested - 1, -#requested - 1) == "/"
end

--- Resolve one unambiguous existing match from the current project index
-- @param reference table parsed file reference
-- @param project_files table indexed project entries
-- @param attempts table|nil optional checked candidates
-- @return table|nil resolved_reference
local function resolve_from_project(reference, project_files, attempts)
	local requested = normalize_lookup_path(reference.filename)
	local matches = {}
	local seen = {}
	local indexed_count = 0

	for _, entry in ipairs(project_files) do
		if type(entry) == "table"
			and type(entry.filename) == "string"
			and (entry.type == nil or entry.type == "file")
		then
			indexed_count = indexed_count + 1
			if project_path_matches(normalize_lookup_path(entry.filename), requested) then
				local filename = system.absolute_path(entry.filename)
				local key = filename and system.path_key(filename)
				if key and not seen[key] and system.is_file(filename) == true then
					seen[key] = true
					matches[#matches + 1] = filename
				end
			end
		end
	end

	record_project_attempt(attempts, reference.filename, matches, indexed_count)
	if #matches == 1 then
		return copy_resolved_reference(reference, matches[1])
	end
end

--- Resolve one filename directly or from the source document and its parents
-- @param reference table parsed file reference
-- @param source_filename string|nil current document filename
-- @param attempts table|nil optional checked filesystem candidates
-- @param options table|nil optional search_paths and project_files
-- @return table|nil resolved_reference
local function resolve_candidate(reference, source_filename, attempts, options)
	if is_absolute_path(reference.filename) then
		return resolve_from(reference, nil, attempts, "absolute", 0)
	end

	local source = type(source_filename) == "string"
		and source_filename ~= ""
		and system.parse_file_reference(source_filename, false)
	local source_path = source and system.absolute_path(source.filename)
	local directory = source_path and system.path_dirname(source_path)

	if not directory then
		return resolve_from(reference, nil, attempts, "current", 0)
	end

	if directory then
		local parent_levels = 0
		while directory do
			local resolved = resolve_from(reference, directory, attempts, "source", parent_levels)
			if resolved then
				return resolved
			end

			local parent = system.path_dirname(directory)
			if not parent or system.path_key(parent) == system.path_key(directory) then
				break
			end
			directory = parent
			parent_levels = parent_levels + 1
		end
	else
		local resolved = resolve_from(reference, nil, attempts, "current", 0)
		if resolved then
			return resolved
		end
	end

	options = type(options) == "table" and options or {}
	local search_paths = type(options.search_paths) == "table" and options.search_paths or {}
	for index, search_path in ipairs(search_paths) do
		if type(search_path) == "string" and search_path ~= "" then
			local resolved = resolve_from(reference, search_path, attempts, "search_path", index)
			if resolved then
				return resolved
			end
		end
	end

	if type(options.project_files) == "table" then
		return resolve_from_project(reference, options.project_files, attempts)
	end

	return nil
end

--- Resolve a reference and any alternative filenames supplied by its format rule
-- @param reference table parsed file reference
-- @param source_filename string|nil current document filename
-- @param attempts table|nil optional checked filesystem candidates
-- @param options table|nil optional search_paths and project_files
-- @return table|nil resolved_reference
function file_reference_resolver.resolve(reference, source_filename, attempts, options)
	if type(reference) ~= "table"
		or type(reference.filename) ~= "string"
		or reference.filename == ""
	then
		return nil
	end

	for _, candidate in ipairs(get_reference_candidates(reference, source_filename)) do
		local resolved = resolve_candidate(candidate, source_filename, attempts, options)
		if resolved then
			return resolved
		end
	end

	return nil
end

return file_reference_resolver
