/*
 * Copyright (c) 2012-2026 Daniele Bartolini et al.
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

namespace Crown
{
public enum ImportResult
{
	SUCCESS, ///< Data imported successfully.
	ERROR,   ///< Error during import or elsewhere.
	CANCEL,  ///< User cancelled the import.
}

public delegate void Import(ImportResult result);

public class Project
{
	public const string LEVEL_EDITOR_TEST_NAME = "_level_editor_test";

	public delegate void ImporterDelegate(Import import_result
		, Database database
		, string destination_dir
		, SList<string> filenames
		, Gtk.Window? parent_window
		);

	[Compact]
	public struct ImporterData
	{
		public unowned ImporterDelegate delegate;
		public Gee.ArrayList<string> extensions;
		public double order;
		public Gtk.FileFilter _filter;
		public unowned Import import_result;

		ImporterData()
		{
			delegate = null;
			extensions = new Gee.ArrayList<string>();
			order = 0.0;
			_filter = new Gtk.FileFilter();
			import_result = null;
		}

		public bool can_import_extension(string extension)
		{
			string e = extension.down();

			foreach (var ext in extensions) {
				if (e == ext)
					return true;
			}

			return false;
		}

		public bool can_import_filenames(GLib.SList<string> filenames)
		{
			foreach (var filename in filenames) {
				if (!can_import_extension(path_extension(filename)))
					return false;
			}

			return true;
		}
	}

	// Data
	public File? _source_dir;
	public File _toolchain_dir;
	public File _data_dir;
	public File _user_dir;
	public File _level_editor_test_level;
	public File _level_editor_test_package;
	public string _platform;
	public Database _files;
	public Gee.HashMap<string, Guid?> _map;
	public ImporterData _all_extensions_importer_data;
	public Gee.ArrayList<ImporterData?> _importers;
	public bool _data_compiled;
	public Hashtable _data_index;

	public signal void file_added(string type, string name, uint64 size, uint64 mtime);
	public signal void file_changed(string type, string name, uint64 size, uint64 mtime);
	public signal void file_removed(string type, string name);
	public signal void tree_added(string name);
	public signal void tree_removed(string name);
	public signal void files_reset();
	public signal void project_reset();
	public signal void project_loaded();

	public Project()
	{
#if CROWN_PLATFORM_WINDOWS
		_platform = "windows";
#else
		_platform = "linux";
#endif
		_files = new Database(this);
		_map = new Gee.HashMap<string, Guid?>();
		_all_extensions_importer_data = ImporterData();
		_all_extensions_importer_data.delegate = import_all_extensions;
		_importers = new Gee.ArrayList<ImporterData?>();
		_data_compiled = false;
		_data_index = new Hashtable();
	}

	public void data_compiled()
	{
		_data_compiled = true;

		try {
			string index_path = Path.build_filename(_data_dir.get_path(), "data_index.sjson");
			_data_index = SJSON.load_from_path(index_path);
		} catch (JsonSyntaxError e) {
			loge(e.message);
		}
	}

	public uint64 mtime(string type, string name)
	{
		var path = ResourceId.path(type, name);
		Guid id = _map[path];
		string mtime = _files.get_string(id, "mtime");
		return uint64.parse(mtime);
	}

	public void reset_files()
	{
		_files.reset();
		_map.clear();

		files_reset();
	}

	public void reset()
	{
		project_reset();

		reset_files();
		_source_dir = null;
	}

	public bool is_loaded()
	{
		return _source_dir != null;
	}

	public int load(string source_dir)
	{
		// Naively check whether the selected folder contains a Crown project.
		if (!GLib.File.new_for_path(GLib.Path.build_filename(source_dir, "boot.config")).query_exists()
			|| !GLib.File.new_for_path(GLib.Path.build_filename(source_dir, "global.physics_config")).query_exists())
			return 1;

		reset();

		_source_dir = File.new_for_path(source_dir);
		_data_dir   = File.new_for_path(_source_dir.get_path() + "_" + _platform);

		_level_editor_test_level = File.new_for_path(Path.build_filename(_source_dir.get_path(), LEVEL_EDITOR_TEST_NAME + ".level"));
		_level_editor_test_package = File.new_for_path(Path.build_filename(_source_dir.get_path(), LEVEL_EDITOR_TEST_NAME + ".package"));

		// Cleanup source directory from previous runs' garbage
		delete_garbage();

		_user_dir = GLib.File.new_for_path(GLib.Path.build_filename(Crown._data_dir.get_path(), "projects", StringId64(source_dir).to_string()));
		try {
			_user_dir.make_directory_with_parents();
		} catch (Error e) {
			/* Nobody cares */
		}

		project_loaded();
		return 0;
	}

	public void set_toolchain_dir(string toolchain_dir)
	{
		_toolchain_dir = File.new_for_path(toolchain_dir);
	}

	public static void create_initial_files(string source_dir)
	{
		// Write boot.config
		{
			string text = "// Lua script to launch on boot"
				+ "\nboot_script = \"core/game/boot\""
				+ "\n"
				+ "\n// Package to load on boot"
				+ "\nboot_package = \"boot\""
				+ "\n"
				+ "\nwindow_title = \"New Project\""
				+ "\n"
				+ "\n// Linux-only configs"
				+ "\nlinux = {"
				+ "\n	renderer = {"
				+ "\n		resolution = [ 1280 720 ]"
				+ "\n	}"
				+ "\n}"
				+ "\n"
				+ "\n// Windows-only configs"
				+ "\nwindows = {"
				+ "\n	renderer = {"
				+ "\n		resolution = [ 1280 720 ]"
				+ "\n	}"
				+ "\n}"
				+ "\n"
				;

			string path = Path.build_filename(source_dir, "boot.config");
			FileStream fs = FileStream.open(path, "wb");
			if (fs != null)
				fs.write(text.data);
		}

		// Write boot.package
		{
			string text = "lua = ["
				+ "\n	\"core/game/boot\""
				+ "\n]"
				+ "\nshader = ["
				+ "\n	\"core/shaders/default\""
				+ "\n]"
				+ "\nphysics_config = ["
				+ "\n	\"global\""
				+ "\n]"
				+ "\nunit = ["
				+ "\n	\"core/units/camera\""
				+ "\n]"
				+ "\n"
				;

			string path = Path.build_filename(source_dir, "boot.package");
			FileStream fs = FileStream.open(path, "wb");
			if (fs != null)
				fs.write(text.data);
		}

		// Write global.physics_config
		{
			string text = "materials = {"
				+ "\n	default = { friction = 0.8 rolling_friction = 0.5 restitution = 0.81 }"
				+ "\n}"
				+ "\n"
				+ "\ncollision_filters = {"
				+ "\n	no_collision = { collides_with = [] }"
				+ "\n	default = { collides_with = [ \"default\" ] }"
				+ "\n}"
				+ "\n"
				+ "\nactors = {"
				+ "\n	static = { dynamic = false }"
				+ "\n	dynamic = { dynamic = true }"
				+ "\n	keyframed = { dynamic = true kinematic = true disable_gravity = true }"
				+ "\n	trigger = { trigger = true }"
				+ "\n}"
				+ "\n"
				;

			string path = Path.build_filename(source_dir, "global.physics_config");
			FileStream fs = FileStream.open(path, "wb");
			if (fs != null)
				fs.write(text.data);
		}

		// Write main.lua
		{
			string text = "require \"core/game/camera\""
				+ "\n"
				+ "\nGame = Game or {"
				+ "\n	scene_graph = nil,"
				+ "\n	physics_world = nil,"
				+ "\n	render_world = nil,"
				+ "\n	camera = nil,"
				+ "\n}"
				+ "\n"
				+ "\nGameBase.game = Game"
				+ "\nGameBase.game_level = nil"
				+ "\n"
				+ "\nfunction Game.level_loaded()"
				+ "\n	Device.enable_resource_autoload(true)"
				+ "\n"
				+ "\n	Game.scene_graph = World.scene_graph(GameBase.world)"
				+ "\n	Game.physics_world = World.physics_world(GameBase.world)"
				+ "\n	Game.render_world = World.render_world(GameBase.world)"
				+ "\n	Game.camera = FPSCamera(GameBase.world, GameBase.camera_unit)"
				+ "\nend"
				+ "\n"
				+ "\nfunction Game.update(dt)"
				+ "\n	-- Stop the engine when the 'ESC' key is released."
				+ "\n	if Keyboard.released(Keyboard.button_id(\"escape\")) then"
				+ "\n		Device.quit()"
				+ "\n	end"
				+ "\n"
				+ "\n	-- Update camera."
				+ "\n	local dx = Keyboard.button(Keyboard.button_id(\"d\")) - Keyboard.button(Keyboard.button_id(\"a\"))"
				+ "\n	local dy = Keyboard.button(Keyboard.button_id(\"w\")) - Keyboard.button(Keyboard.button_id(\"s\"))"
				+ "\n	local cursor_delta = Vector3.zero()"
				+ "\n	if Mouse.pressed(Mouse.button_id(\"right\")) then move = true end"
				+ "\n	if Mouse.released(Mouse.button_id(\"right\")) then move = false end"
				+ "\n	if move then cursor_delta = Mouse.axis(Mouse.axis_id(\"cursor_delta\")) end"
				+ "\n	Game.camera:rotate(dt, cursor_delta.x, cursor_delta.y)"
				+ "\n	Game.camera:move(dt, dx, dy)"
				+ "\nend"
				+ "\n"
				+ "\nfunction Game.render(dt)"
				+ "\nend"
				+ "\n"
				+ "\nfunction Game.shutdown()"
				+ "\nend"
				+ "\n"
				;

			string path = Path.build_filename(source_dir, "main.lua");
			FileStream fs = FileStream.open(path, "wb");
			if (fs != null)
				fs.write(text.data);
		}
	}

	public int create_script(string directory, string name, bool empty)
	{
		string script_name = Path.build_filename(directory, name);
		string script_path = script_name + ".lua";
		string path = this.absolute_path(script_path);
		string behavior_name = camel_case(script_name.replace("/", " ")).replace(" ", "");

		FileStream fs = FileStream.open(path, "wb");
		if (fs != null) {
			if (empty) {
				return fs.puts("\n");
			} else {
				string text = "-- Note: the following table must be global and uniquely named."
					+ "\nBehavior = Behavior or {"
					+ "\n	data = {}"
					+ "\n}"
					+ "\n"
					+ "\nlocal data = Behavior.data"
					+ "\n"
					+ "\n-- Called after units are spawned into a world."
					+ "\nfunction Behavior.spawned(world, units)"
					+ "\n	if data[world] == nil then"
					+ "\n		data[world] = {}"
					+ "\n	end"
					+ "\n"
					+ "\n	local world_data = data[world]"
					+ "\n"
					+ "\n	for _, unit in pairs(units) do"
					+ "\n		-- Store instance-specific data."
					+ "\n		if world_data[unit] == nil then"
					+ "\n			world_data[unit] = {}"
					+ "\n		end"
					+ "\n	end"
					+ "\nend"
					+ "\n"
					+ "\n-- Called once per frame. Optional."
					+ "\nfunction Behavior.update(world, dt)"
					+ "\n	local world_data = data[world]"
					+ "\n"
					+ "\n	for unit, unit_data in pairs(world_data) do"
					+ "\n		-- Update unit."
					+ "\n	end"
					+ "\nend"
					+ "\n"
					+ "\n-- Called before units are unspawned from a world."
					+ "\nfunction Behavior.unspawned(world, units)"
					+ "\n	local world_data = data[world]"
					+ "\n"
					+ "\n	-- Cleanup."
					+ "\n	for _, unit in pairs(units) do"
					+ "\n		if world_data[unit] then"
					+ "\n			world_data[unit] = nil"
					+ "\n		end"
					+ "\n	end"
					+ "\nend"
					+ "\n"
					+ "\nreturn Behavior"
					+ "\n"
					;
				return fs.puts(text.replace("Behavior", behavior_name));
			}
		}

		return -1;
	}

	public int create_unit(string directory, string name)
	{
		string unit_path = Path.build_filename(directory, name + ".unit");
		string path = this.absolute_path(unit_path);

		Database db = new Database(this, null);
		Guid unit_id = Guid.new_guid();
		Unit unit = Unit(db, unit_id);
		unit.create_empty();

		return db.dump(path, unit_id);
	}

	public int create_state_machine(string directory, string name, string? skeleton_name)
	{
		string resource_name = Path.build_filename(directory, name);

		Database db = new Database(this, null);
		Guid machine_id = Guid.new_guid();

		StateMachineResource machine;
		if (skeleton_name == null)
			machine = StateMachineResource.sprite(db, machine_id, null);
		else
			machine = StateMachineResource.mesh(db, machine_id, skeleton_name);

		return machine.save(this, resource_name);
	}

	public int create_material(string directory, string name)
	{
		string resource_name = Path.build_filename(directory, name);

		Database db = new Database(this, null);
		Guid material_id = Guid.new_guid();
		MaterialResource material_resource = MaterialResource.mesh(db, material_id);
		return material_resource.save(this, resource_name);
	}

	// Returns the absolute path to the source directory.
	public string source_dir()
	{
		if (_source_dir == null)
			return "";
		else
			return _source_dir.get_path();
	}

	// Returns the absolute path to the toolchain directory.
	public string toolchain_dir()
	{
		return _toolchain_dir.get_path();
	}

	// Returns the absolute path to the data directory.
	public string data_dir()
	{
		return _data_dir.get_path();
	}

	// Returns the absolute path to the user-specific data for this project.
	public string user_dir()
	{
		return _user_dir.get_path();
	}

	public string platform()
	{
		return _platform;
	}

	public string name()
	{
		string sd = source_dir();
		return sd.substring(sd.last_index_of_char(GLib.Path.DIR_SEPARATOR) + 1);
	}

	public bool path_is_within_source_dir(string path)
	{
		GLib.File file = GLib.File.new_for_path(path);
		return file.has_prefix(_source_dir);
	}

	public void delete_garbage()
	{
		try {
			_level_editor_test_level.delete();
			_level_editor_test_package.delete();
		} catch (GLib.Error e) {
			// Ignored
		}
	}

	/// Converts the @a resource_id to its corresponding human-readable @a
	/// resource_name. It returns true if the conversion is successful, otherwise
	/// it returns false and sets @a resource_name to the value of @a resource_id.
	public bool resource_id_to_name(out string resource_name, string resource_id)
	{
		Value? name = _data_index[resource_id];
		if (name != null) {
			resource_name = (string)name;
			return true;
		}

		resource_name = resource_id;
		return false;
	}

	public Database files()
	{
		return _files;
	}

	public void add_file(string path, uint64 size, uint64 mtime)
	{
		string type = path_extension(path);
		string name = type == "" ? path : path.substring(0, path.last_index_of("."));

		Guid id = Guid.new_guid();
		_files.create(id, OBJECT_TYPE_FILE);
		_files.set_string(id, "path", path);
		_files.set_string(id, "type", type);
		_files.set_string(id, "name", name);
		_files.set_string(id, "size", size.to_string());
		_files.set_string(id, "mtime", mtime.to_string());
		_files.add_to_set(GUID_ZERO, "data", id);

		_map[path] = id;

		file_added(type, name, size, mtime);
	}

	public void change_file(string path, uint64 size, uint64 mtime)
	{
		string type = path_extension(path);
		string name = type == "" ? path : path.substring(0, path.last_index_of("."));

		Guid id = _map[path];
		_files.set_string(id, "size", size.to_string());
		_files.set_string(id, "mtime", mtime.to_string());

		_data_compiled = false;

		file_changed(type, name, size, mtime);
	}

	public void remove_file(string path)
	{
		if (!_map.has_key(path)) {
			logw("remove_file: map does not contain path: %s".printf(path));
			return;
		}

		Guid id = _map[path];
		file_removed(_files.get_string(id, "type"), _files.get_string(id, "name"));

		_files.remove_from_set(GUID_ZERO, "data", id);
		_files.destroy(id);

		_map.unset(path);
	}

	public void add_tree(string path)
	{
		tree_added(path);
	}

	public void remove_tree(string path)
	{
		tree_removed(path);
	}

	public string resource_filename(string absolute_path)
	{
		string prefix = _source_dir.get_path();

		if (absolute_path.has_prefix(_toolchain_dir.get_path() + "/core"))
			prefix = _toolchain_dir.get_path();

		return File.new_for_path(prefix).get_relative_path(File.new_for_path(absolute_path));
	}

	public string absolute_path(string resource_path)
	{
		string prefix = _source_dir.get_path();

		if (resource_path.has_prefix("core/") || resource_path == "core")
			prefix = _toolchain_dir.get_path();

		return Path.build_filename(prefix, resource_path);
	}

	public static void import_all_extensions(Import import_result
		, Database database
		, string destination_dir
		, SList<string> filenames
		, Gtk.Window? parent_window
		)
	{
		Project project = database._project;

		Gee.ArrayList<string> paths = new Gee.ArrayList<string>();
		foreach (var item in filenames)
			paths.add(item);

		paths.sort((a, b) => {
				int ext_a = a.last_index_of_char('.');
				int ext_b = b.last_index_of_char('.');
				return strcmp(a[ext_a : a.length], b[ext_b : b.length]);
			});

		while (paths.size != 0) {
			// Find importer for the first file in the list of selected filenames.
			ImporterData? importer = project.find_importer_for_path(paths[0]);
			if (importer == null) {
				import_result(ImportResult.ERROR);
				return;
			}

			// Create the list of all filenames importable by importer.
			Gee.ArrayList<string> importables = new Gee.ArrayList<string>();
			var cur = paths.list_iterator();
			for (var has_next = cur.next(); has_next; has_next = cur.next()) {
				string path = paths[cur.index()];

				if (importer.can_import_extension(path_extension(path))) {
					importables.add(path);
					cur.remove();
				}
			}

			// If importables is empty, filenames must have been filled with
			// un-importable filenames...
			if (importables.size == 0) {
				import_result(ImportResult.ERROR);
				return;
			}

			// Convert importables to SList<string> to be used as delegate param.
			SList<string> importables_list = new SList<string>();
			foreach (var item in importables)
				importables_list.append(item);

			importer.delegate(import_result, database, destination_dir, importables_list, parent_window);
		}
	}

	public class FileFilterFuncData
	{
		public string extension;

		public FileFilterFuncData(string ext)
		{
			extension = ext;
		}

		public bool handler(Gtk.FileFilterInfo info)
		{
			return info.filename.down().has_suffix("." + extension);
		}
	}

	// Returns a Gtk.FileFilter based on file @a extensions list.
	public Gtk.FileFilter create_gtk_file_filter(string name, Gee.ArrayList<string> extensions)
	{
		Gtk.FileFilter filter = new Gtk.FileFilter();

		string extensions_comma_separated = "";
		foreach (var ext in extensions) {
			extensions_comma_separated += "*.%s, ".printf(ext);
			FileFilterFuncData data = new FileFilterFuncData(ext);
			filter.add_custom(Gtk.FileFilterFlags.FILENAME, data.handler);
		}
		filter.set_filter_name(name + " (%s)".printf(extensions_comma_separated[0 : -2]));

		return filter;
	}

	public void register_importer_internal(string name, ref ImporterData data)
	{
		data._filter = create_gtk_file_filter(name, data.extensions);
		_importers.add(data);
		_importers.sort((a, b) => { return a.order < b.order ? -1 : 1; });

		// Skip duplicated extensions.
		foreach (string ext in data.extensions) {
			if (!_all_extensions_importer_data.extensions.contains(ext))
				_all_extensions_importer_data.extensions.add(ext);
		}

		_all_extensions_importer_data._filter = create_gtk_file_filter("All", _all_extensions_importer_data.extensions);
	}

	// Registers an @a importer for importing source data with the given @a
	// extensions. @a order is used to establish precedence when distinct importers
	// support similar extensions; lower values have higher precedence.
	public void register_importer(string name, string[] extensions, ImporterDelegate importer, Import import_result, double order)
	{
		ImporterData data = ImporterData();
		data.delegate = importer;
		data.extensions.add_all_array(extensions);
		data.order = order;
		data.import_result = import_result;

		register_importer_internal(name, ref data);
	}

	// Returns the preferable importer (lowest order values) which can import files
	// with the given @a extension.
	public ImporterData? find_importer_for_extension(string extension)
	{
		foreach (var imp in _importers) {
			if (imp.can_import_extension(extension))
				return imp;
		}

		return null;
	}

	public ImporterData? find_importer_for_path(string path)
	{
		return find_importer_for_extension(path_extension(path));
	}

	public bool is_type_importable(string type)
	{
		return find_importer_for_extension(type) != null;
	}

	public bool is_type_image(string type)
	{
		return type == "dds"
			|| type == "exr"
			|| type == "jpg"
			|| type == "ktx"
			|| type == "pvr"
			|| type == "tga"
			|| type == "png"
			;
	}

	public bool is_type_mesh(string type)
	{
		return type == "fbx"
			|| type == "obj"
			|| type == "mesh"
			;
	}

	public bool is_type_sound(string type)
	{
		return type == "mp3"
			|| type == "ogg"
			|| type == "wav"
			;
	}

	public bool is_type_font(string type)
	{
		return type == "otf"
			|| type == "ttf"
			;
	}

	public void import_filenames(string? destination_dir
		, GLib.SList<string> filenames
		, Import import_result
		, ImporterDelegate? importer
		, Database database
		, Gtk.Window? parent_window = null
		)
	{
		GLib.SList<string> _filenames = new GLib.SList<string>();
		foreach (var s in filenames)
			_filenames.append(s);

		if (destination_dir != null) {
			importer(import_result, database, this.absolute_path(destination_dir), filenames, parent_window);
		} else {
			Gtk.FileChooserDialog fcd = new Gtk.FileChooserDialog("Select destination folder..."
				, parent_window
				, Gtk.FileChooserAction.SELECT_FOLDER
				, "Cancel"
				, Gtk.ResponseType.CANCEL
				, "Select"
				, Gtk.ResponseType.ACCEPT
				);
			try {
				fcd.set_current_folder_file(GLib.File.new_for_path(this.source_dir()));
			} catch (GLib.Error e) {
				loge(e.message);
			}

			fcd.response.connect((response_id) => {
					if (response_id == Gtk.ResponseType.ACCEPT)
						importer(import_result, database, fcd.get_file().get_path(), _filenames, parent_window);
					fcd.destroy();
				});

			fcd.show_all();
		}
	}

	public void import(string? destination_dir
		, string[] files
		, Import import_result
		, Database database
		, Gtk.Window? parent_window = null
		)
	{
		GLib.SList<string> filenames = new GLib.SList<string>();

		if (files.length == 0) {
			Gtk.FileChooserDialog fcd = new Gtk.FileChooserDialog("Import..."
				, parent_window
				, Gtk.FileChooserAction.OPEN
				, "Cancel"
				, Gtk.ResponseType.CANCEL
				, "Open"
				, Gtk.ResponseType.ACCEPT
				);
			fcd.select_multiple = true;
			fcd.add_filter(_all_extensions_importer_data._filter);
			fcd.set_filter(_all_extensions_importer_data._filter);
			foreach (var importer in _importers)
				fcd.add_filter(importer._filter);

			fcd.response.connect((response_id) => {
					if (response_id == Gtk.ResponseType.ACCEPT) {
						foreach (var f in fcd.get_files())
							filenames.append(f.get_path());

						// Find importer callback.
						unowned ImporterDelegate? importer = null;
						foreach (var imp in _importers) {
							if (imp._filter == fcd.get_filter() && imp.can_import_filenames(filenames)) {
								importer = imp.delegate;
								break;
							}
						}

						// Fallback if no importer found.
						if (importer == null)
							importer = _all_extensions_importer_data.delegate;

						import_filenames(destination_dir, filenames, import_result, importer, database, parent_window);
					}
					fcd.destroy();
				});

			fcd.show_all();
		} else {
			foreach (var f in files)
				filenames.append(f);
			import_filenames(destination_dir, filenames, import_result, _all_extensions_importer_data.delegate, database, parent_window);
		}
	}

	public void delete_tree(GLib.File file) throws Error
	{
		GLib.FileEnumerator fe = file.enumerate_children("standard::*"
			, GLib.FileQueryInfoFlags.NOFOLLOW_SYMLINKS
			);

		GLib.FileInfo info = null;
		while ((info = fe.next_file()) != null) {
			GLib.File subfile = file.resolve_relative_path(info.get_name());

			if (info.get_file_type() == GLib.FileType.DIRECTORY)
				delete_tree(subfile);
			else
				subfile.delete();
		}

		file.delete();
	}
}

} /* namespace Crown */
