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

--- Temporary file and filesystem helper operations
-- Keep temp-file session state outside 'core.init'
-- @module core.file.ops

local common = require("core.common")
local config = require("core.config")

local fileops = {}

local temp_uid = math.floor((system.get_time() * 1000) % 0xffffffff)
local temp_file_prefix = string.format(".lxtemp%08x", temp_uid)
local temp_file_counter = 0
local temp_files = {}
local reserved_windows_names = {
	aux = true,
	["clock$"] = true,
	con = true,
	nul = true,
	prn = true,
}

for index = 1, 9 do
	reserved_windows_names["com" .. index] = true
	reserved_windows_names["lpt" .. index] = true
end

--- Return whether one exact filename works on every supported platform
-- @param name string filename without a directory
-- @return boolean portable
local function is_portable_filename(name)
	-- Pattern breakdown: reject control bytes and characters forbidden by Windows filenames
	if name == "" or name == "." or name == ".." or name:find("[%z\1-\31<>:\"/\\|?*]") then
		return false
	end
	-- Pattern breakdown: Windows strips trailing dots and spaces before opening a file
	if name:find("[%. ]$") then
		return false
	end

	local first_dot = name:find(".", 1, true)
	local stem = (first_dot and name:sub(1, first_dot - 1) or name):lower()

	return not reserved_windows_names[stem]
end

--- Delete temporary files created during this session
function fileops.delete_temp_files()
	for path in pairs(temp_files) do
		os.remove(path)
		temp_files[path] = nil
	end
end

--- Return one temporary filename without a directory
-- @param name string|nil exact filename
-- @return string filename
function fileops.temp_filename(name)
	if name ~= nil then
		name = tostring(name)
		assert(is_portable_filename(name), "temporary filename must be one portable filename")

		return name
	end

	temp_file_counter = temp_file_counter + 1

	return temp_file_prefix .. string.format("%06x", temp_file_counter)
end

--- Return and track one complete temporary file path
-- Portable builds keep temporary files under 'user/temp'
-- @param name string|nil exact filename
-- @return string|nil path
-- @return string|nil error_message
function fileops.temp_path(name)
	local directory = HAVE_LOCAL_DATA
		and system.join_path(USER_DIR, "temp")
		or system.temp_path()
	local created, create_error = system.mkdir(directory)

	if not created then
		return nil, create_error
	end

	local path = system.join_path(directory, fileops.temp_filename(name))
	temp_files[path] = true

	return path
end

--- Check whether a path is a filesystem root
-- @param path string|nil path to check
-- @return boolean is_root
local function is_root_directory(path)
	local normalized_path = (system.absolute_path(path) or path or ""):gsub("\\", "/")

	return normalized_path == "/" or normalized_path:match("^%a:[/\\]?$") ~= nil
end

--- Sort project file entries by filename
-- @param first_entry table file entry
-- @param second_entry table file entry
-- @return boolean ordered
local function compare_file(first_entry, second_entry)
	return first_entry.filename < second_entry.filename
end

--- Build one cached project file entry
-- @param path string file path
-- @param info table filesystem metadata
-- @return table entry
local function make_file_entry(path, info)
	return {
		filename = path,
		modified = info.modified,
		size = info.size,
		type = info.type,
	}
end

--- Collect project files under one path with configured limits
-- @param path string directory path
-- @param files table|nil collected file list
-- @param state table|nil scan state
-- @return table files
function fileops.get_files(path, files, state)
	coroutine.yield()
	files = files or {}
	state = state or { file_count = 0, root_is_root = nil }

	if state.root_is_root == nil then
		state.root_is_root = is_root_directory(path)
	end

	local size_limit = config.file_size_limit * 10e5
	local file_limit = math.max(1, tonumber(config.project_scan_limit_files) or 2000)

	if state.file_count >= file_limit then
		return files
	end

	local all_entries = system.list_dir(path) or {}
	local directories = {}
	local file_entries = {}

	for _, name in ipairs(all_entries) do
		if not common.match_pattern(name, config.ignore_files) then
			local entry_path = (path ~= "." and path .. PATHSEP or "") .. name
			local info = system.get_file_info(entry_path)

			-- Linked directories can point back to an ancestor and must not be recursed
			if info and not (info.type == "dir" and info.is_link) and info.size < size_limit then
				local entry = make_file_entry(entry_path, info)
				table.insert(info.type == "dir" and directories or file_entries, entry)
			end
		end
	end

	if not state.root_is_root then
		table.sort(directories, compare_file)

		for _, entry in ipairs(directories) do
			if state.file_count >= file_limit then
				break
			end

			fileops.get_files(entry.filename, files, state)
		end
	end

	table.sort(file_entries, compare_file)

	for _, entry in ipairs(file_entries) do
		if state.file_count >= file_limit then
			break
		end

		table.insert(files, entry)
		state.file_count = state.file_count + 1

		if state.on_file then
			state.on_file(entry)
		end
	end

	return files
end

return fileops
