//! Defines the `path` module, used in the lua engine.
//!
//! ---
//!
//! ## Lua documentation
//! The `aip.path` module exposes functions used to interact with file paths.
//!
//! ### Functions
//!
//! - `aip.path.split(path: string): parent: string, filename: string`
//! - `aip.path.resolve(path: string): string`
//! - `aip.path.exists(path: string): boolean`
//! - `aip.path.is_file(path: string): boolean`
//! - `aip.path.is_dir(path: string): boolean`
//! - `aip.path.diff(file_path: string, base_path: string): string`
//! - `aip.path.parent(path: string): string | nil`
//! - `aip.path.join(base: string, ...parts: string | string[]): string`
//! - `aip.path.matches_glob(path: string | nil, globs: string | string[]): boolean | nil`
//! - `aip.path.sort_by_globs(files: any[], globs: string | string[], options?: any): any[]`
//! - `aip.path.parse(path: string | nil): table | nil`
//!

use crate::Result;
use crate::dir_context::PathResolver;
use crate::runtime::Runtime;
use crate::script::support::{into_option_string, into_vec_of_strings};
use crate::support::W;
use crate::types::FileInfo;
use mlua::{FromLua, IntoLua, Lua, MultiValue, Table, Value, Variadic};
use simple_fs::{SPath, SortByGlobsOptions, get_glob_set, sort_by_globs};
use std::path::Path;

pub fn init_module(lua: &Lua, runtime: &Runtime) -> Result<Table> {
	let table = lua.create_table()?;

	// -- split
	let path_split_fn = lua.create_function(path_split)?;

	// -- exists
	let rt = runtime.clone();
	let path_exists_fn = lua.create_function(move |_lua, path: String| path_exists(&rt, path))?;

	// -- resolve
	let rt = runtime.clone();
	let path_resolve_fn = lua.create_function(move |_lua, path: String| path_resolve(&rt, path))?;

	// -- is_file
	let rt = runtime.clone();
	let path_is_file_fn = lua.create_function(move |_lua, path: String| path_is_file(&rt, path))?;

	// -- is_dir
	let rt = runtime.clone();
	let path_is_dir_fn = lua.create_function(move |_lua, path: String| path_is_dir(&rt, path))?;

	// -- diff
	let rt = runtime.clone();
	let path_diff_fn = lua
		.create_function(move |_lua, (file_path, base_path): (String, String)| path_diff(&rt, file_path, base_path))?;

	// -- join
	let path_join =
		lua.create_function(move |lua, (base, args): (String, Variadic<Value>)| path_join(lua, base, args))?;

	// -- parse
	let rt = runtime.clone();
	let path_parse_fn = lua.create_function(move |lua, value: Value| path_parse(lua, &rt, value))?;

	// -- parent
	let path_parent_fn = lua.create_function(move |_lua, path: String| path_parent(path))?;

	// -- matches_glob
	let path_matches_glob_fn =
		lua.create_function(move |_lua, (path, globs): (Value, Value)| path_matches_glob(path, globs))?;

	// -- sort_by_globs
	let path_sort_by_globs_fn = lua.create_function(move |lua, (files, globs, options): (Value, Value, Value)| {
		path_sort_by_globs(lua, files, globs, options)
	})?;

	// -- Add all functions to the module
	table.set("parse", path_parse_fn)?;
	table.set("resolve", path_resolve_fn)?;
	table.set("join", path_join)?;
	table.set("exists", path_exists_fn)?;
	table.set("is_file", path_is_file_fn)?;
	table.set("is_dir", path_is_dir_fn)?;
	table.set("diff", path_diff_fn)?;
	table.set("parent", path_parent_fn)?;
	table.set("matches_glob", path_matches_glob_fn)?;
	table.set("split", path_split_fn)?;
	table.set("sort_by_globs", path_sort_by_globs_fn)?;

	Ok(table)
}

// region:    --- Lua Functions

/// ## Lua Documentation
///
/// Parses a path string and returns a [FileInfo](#filemeta) table representation of its components.
///
/// ```lua
/// -- API Signature
/// aip.path.parse(path: string | nil): table | nil
/// ```
///
/// Parses the given path string into a structured table containing components like `dir`, `name`, `stem`, `ext`, etc., without checking file existence or metadata.
///
/// ### Arguments
///
/// - `path: string | nil`: The path string to parse. If `nil`, the function returns `nil`.
///
/// ### Returns
///
/// - `table | nil`: A [FileInfo](#filemeta) table representing the parsed path components if `path` is a string. Returns `nil` if the input `path` was `nil`. Note that `ctime`, `mtime`, and `size` fields will be `nil` as this function only parses the string, it does not access the filesystem.
///
/// ### Example
///
/// ```lua
/// local parsed = aip.path.parse("some/folder/file.txt")
/// -- parsed will be similar to { path = "some/folder/file.txt", dir = "some/folder", name = "file.txt", stem = "file", ext = "txt", ctime = nil, ... }
/// print(parsed.name) -- Output: "file.txt"
///
/// local nil_result = aip.path.parse(nil)
/// -- nil_result will be nil
/// ```
///
/// ### Error
///
/// Returns an error (Lua table `{ error: string }`) if the path string is provided but is invalid and cannot be parsed into a valid SPath object.
///
/// ```ts
/// {
///   error: string // Error message
/// }
/// ```
fn path_parse(lua: &Lua, runtime: &Runtime, path: Value) -> mlua::Result<Value> {
	let Some(path) = into_option_string(path, "aip.path.parse")? else {
		return Ok(Value::Nil);
	};

	let spath = SPath::new(path);
	let meta = FileInfo::new(runtime.dir_context(), spath, false);
	meta.into_lua(lua)
}

/// ## Lua Documentation
///
/// Split path into parent, filename.
///
/// ```lua
/// -- API Signature
/// aip.path.split(path: string): parent: string, filename: string
/// ```
///
/// Splits the given path into its parent directory component and its filename component.
///
/// ### Arguments
///
/// - `path: string`: The path to split.
///
/// ### Returns
///
/// Returns two strings: the parent directory path and the filename. Returns empty strings
/// if the path has no parent or no filename, respectively (e.g., splitting "." returns "", ".").
///
/// ```lua
/// -- Example output
/// local parent, filename = "some/path", "file.txt"
/// ```
///
/// ### Example
///
/// ```lua
/// local parent, filename = aip.path.split("folder/file.txt")
/// print(parent)   -- Output: "folder"
/// print(filename) -- Output: "file.txt"
///
/// local parent, filename = aip.path.split("justafile.md")
/// print(parent)   -- Output: ""
/// print(filename) -- Output: "justafile.md"
/// ```
///
/// ### Error
///
/// This function does not typically error, returning empty strings for components that do not exist.
fn path_split(lua: &Lua, path: String) -> mlua::Result<MultiValue> {
	let path = SPath::from(path);

	let parent = path.parent().map(|p| p.to_string()).unwrap_or_default();
	let file_name = path.file_name().unwrap_or_default().to_string();

	Ok(MultiValue::from_vec(vec![
		mlua::Value::String(lua.create_string(parent)?),
		mlua::Value::String(lua.create_string(file_name)?),
	]))
}

/// ## Lua Documentation
///
/// Joins a base path with one or more path segments.
///
/// ```lua
/// -- API Signature
/// aip.path.join(base: string, ...parts: string | string[]): string
/// ```
///
/// Constructs a new path by appending processed segments from `...parts` to the `base` path.
/// Each argument in `...parts` is first converted to a string:
/// - String arguments are used as-is.
/// - List (table) arguments have their string items joined by `/`.
///   These resulting strings are then concatenated together. Finally, this single concatenated string
///   is joined with `base` using system-appropriate path logic (which also normalizes separators like `//` to `/`).
///
/// ### Arguments
///
/// - `base: string`: The initial base path.
/// - `...parts: string | string[]` (variadic): One or more path segments to process and append.
///   Each part can be a single string or a Lua list (table) of strings.
///
/// ### Returns
///
/// - `string`: A new string representing the combined and normalized path.
///
/// ### Example
///
/// ```lua
/// -- Example 1: Basic join
/// print(aip.path.join("dir1/", "file1.txt"))             -- Output: "dir1/file1.txt"
/// print(aip.path.join("dir1", "file1.txt"))              -- Output: "dir1/file1.txt"
///
/// -- Example 2: Joining with a list (table)
/// print(aip.path.join("dir1/", {"subdir", "file2.txt"})) -- Output: "dir1/subdir/file2.txt"
///
/// -- Example 3: Multiple string arguments
/// -- Segments are concatenated, then joined to base.
/// print(aip.path.join("dir1/", "subdir/", "file3.txt"))  -- Output: "dir1/subdir/file3.txt"
/// print(aip.path.join("dir1/", "subdir", "file3.txt"))   -- Output: "dir1/subdirfile3.txt"
///
/// -- Example 4: Mixed arguments (strings and lists)
/// -- Lists are pre-joined with '/', then all resulting strings are concatenated, then joined to base.
/// print(aip.path.join("root/", {"user", "docs"}, "projectA", {"report", "final.pdf"}))
/// -- Output: "root/user/docsprojectAreport/final.pdf"
///
/// -- Example 5: Normalization
/// print(aip.path.join("", {"my-dir//", "///file.txt"}))  -- Output: "my-dir/file.txt"
/// print(aip.path.join("a", "b", "c"))                     -- Output: "a/bc"
/// print(aip.path.join("a/", "b/", "c/"))                  -- Output: "a/b/c/"
/// ```
///
/// ### Error
///
/// Returns an error (Lua table `{ error: string }`) if any of the `parts` arguments cannot be
/// converted to a string or a list of strings (e.g., passing a boolean or a function).
fn path_join(lua: &Lua, base: String, parts: Variadic<Value>) -> mlua::Result<Value> {
	let base = SPath::from(base);

	let mut parts_str = String::new();
	for part in parts {
		// TODO: Could optimize a little to not put a Vec when single value
		let sub_parts = into_vec_of_strings(part, "aip.path.join")?;
		parts_str.push_str(&sub_parts.join("/"))
	}

	let res = base.join(parts_str).to_string();
	let res = res.into_lua(lua)?;
	Ok(res)
}

/// ## Lua Documentation
///
/// Resolves and normalizes a path relative to the workspace.
///
/// ```lua
/// -- API Signature
/// aip.path.resolve(path: string): string
/// ```
///
/// Resolves and normalizes the given path string. This handles relative paths (`.`, `..`),
/// absolute paths, and special aipack pack references (`ns@pack/`, `ns@pack$base/`, `ns@pack$workspace/`).
/// The resulting path is normalized (e.g., `some/../path` becomes `path`). Note: `some/path` was a typo in original, fixed to `path`.
///
/// ### Arguments
///
/// - `path: string`: The path string to resolve and normalize. It can be a relative path, an absolute path, or an aipack pack reference.
///
/// ### Returns
///
/// - `string`: The resolved and normalized path as a string. This path is usually absolute after resolution.
///
/// ### Example
///
/// ```lua
/// local resolved_path = aip.path.resolve("./agent-script/../agent-script/agent-hello.aip")
/// print(resolved_path) -- Output: "/path/to/workspace/agent-script/agent-hello.aip" (example)
///
/// local resolved_pack_path = aip.path.resolve("ns@pack/some/file.txt")
/// print(resolved_pack_path) -- Output: "/path/to/aipack-base/packs/ns/pack/some/file.txt" (example)
/// ```
///
/// ### Error
///
/// Returns an error if the path string cannot be resolved (e.g., invalid pack reference, invalid path format).
///
/// ```ts
/// {
///   error: string // Error message
/// }
/// ```
fn path_resolve(runtime: &Runtime, path: String) -> mlua::Result<String> {
	let path = runtime
		.dir_context()
		.resolve_path(runtime.session(), (&path).into(), PathResolver::WksDir, None)?;
	Ok(path.to_string())
}

/// ## Lua Documentation
///
/// Checks if the specified path exists.
///
/// ```lua
/// -- API Signature
/// aip.path.exists(path: string): boolean
/// ```
///
/// Checks if the file or directory specified by `path` exists. The path is resolved relative to the workspace root.
/// Supports relative paths, absolute paths, and pack references (`ns@pack/...`).
///
/// ### Arguments
///
/// - `path: string`: The path string to check for existence. Can be relative, absolute, or a pack reference.
///
/// ### Returns
///
/// - `boolean`: Returns `true` if a file or directory exists at the specified path, `false` otherwise.
///
/// ### Example
///
/// ```lua
/// if aip.path.exists("README.md") then
///   print("README.md exists.")
/// end
///
/// if aip.path.exists("ns@pack/main.aip") then
///   print("Pack main agent exists.")
/// end
/// ```
///
/// ### Error
///
/// Returns an error if the path string cannot be resolved (e.g., invalid pack reference, invalid path format).
///
/// ```ts
/// {
///   error: string // Error message
/// }
/// ```
fn path_exists(runtime: &Runtime, path: String) -> mlua::Result<bool> {
	Ok(crate::script::support::path_exists(runtime, &path))
}

/// ## Lua Documentation
///
/// Checks if the specified path points to a file.
///
/// ```lua
/// -- API Signature
/// aip.path.is_file(path: string): boolean
/// ```
///
/// Checks if the entity at the specified `path` is a file. The path is resolved relative to the workspace root.
/// Supports relative paths, absolute paths, and pack references (`ns@pack/...`).
///
/// ### Arguments
///
/// - `path: string`: The path string to check. Can be relative, absolute, or a pack reference.
///
/// ### Returns
///
/// - `boolean`: Returns `true` if the path points to an existing file, `false` otherwise.
///
/// ### Example
///
/// ```lua
/// if aip.path.is_file("config.toml") then
///   print("config.toml is a file.")
/// end
///
/// if aip.path.is_file("src/") then
///   print("src/ is a file.") -- This will print false
/// end
/// ```
///
/// ### Error
///
/// Returns an error if the path string cannot be resolved (e.g., invalid pack reference, invalid path format).
///
/// ```ts
/// {
///   error: string // Error message
/// }
/// ```
fn path_is_file(runtime: &Runtime, path: String) -> mlua::Result<bool> {
	let path = runtime
		.dir_context()
		.resolve_path(runtime.session(), (&path).into(), PathResolver::WksDir, None)?;
	Ok(path.is_file())
}

/// ## Lua Documentation
///
/// Computes the relative path from `base_path` to `file_path`.
///
/// ```lua
/// -- API Signature
/// aip.path.diff(file_path: string, base_path: string): string
/// ```
///
/// Calculates the relative path string that navigates from the `base_path` to the `file_path`.
/// Both paths can be absolute or relative.
///
/// ### Arguments
///
/// - `file_path: string`: The target path.
/// - `base_path: string`: The starting path.
///
/// ### Returns
///
/// - `string`: The relative path string from `base_path` to `file_path`. Returns an empty string if the paths are the same or if a relative path cannot be easily computed (e.g., on different drives on Windows).
///
/// ### Example
///
/// ```lua
/// print(aip.path.diff("/a/b/c/file.txt", "/a/b/")) -- Output: "c/file.txt"
/// print(aip.path.diff("/a/b/", "/a/b/c/file.txt")) -- Output: "../.."
/// print(aip.path.diff("/a/b/c", "/a/d/e"))      -- Output: "../../b/c" (example, depends on OS)
/// print(aip.path.diff("folder/file.txt", "folder")) -- Output: "file.txt"
/// print(aip.path.diff("folder/file.txt", "folder/file.txt")) -- Output: ""
/// ```
///
/// ### Error
///
/// Returns an error if the paths are invalid or cannot be processed.
///
/// ```ts
/// {
///   error: string // Error message
/// }
/// ```
fn path_diff(runtime: &Runtime, file_path: String, base_path: String) -> mlua::Result<String> {
	let dir_context = runtime.dir_context();
	let file_path = dir_context.maybe_tilde_path_into_home(SPath::from(file_path));
	let base_path = dir_context.maybe_tilde_path_into_home(SPath::from(base_path));
	// NOTE: Right now, using unwrap_or_default, as this should not happen
	//       But will update simple-fs to utf8 diff by default
	let diff = file_path.diff(base_path).map(|p| p.to_string()).unwrap_or_default();
	Ok(diff)
}

/// ## Lua Documentation
///
/// Checks if the specified path points to a directory.
///
/// ```lua
/// -- API Signature
/// aip.path.is_dir(path: string): boolean
/// ```
///
/// Checks if the entity at the specified `path` is a directory. The path is resolved relative to the workspace root.
/// Supports relative paths, absolute paths, and pack references (`ns@pack/...`).
///
/// ### Arguments
///
/// - `path: string`: The path string to check. Can be relative, absolute, or a pack reference.
///
/// ### Returns
///
/// - `boolean`: Returns `true` if the path points to an existing directory, `false` otherwise.
///
/// ### Example
///
/// ```lua
/// if aip.path.is_dir("src/") then
///   print("src/ is a directory.")
/// end
///
/// if aip.path.is_dir("config.toml") then
///   print("config.toml is a directory.") -- This will print false
/// end
/// ```
///
/// ### Error
///
/// Returns an error if the path string cannot be resolved (e.g., invalid pack reference, invalid path format).
///
/// ```ts
/// {
///   error: string // Error message
/// }
/// ```
fn path_is_dir(runtime: &Runtime, path: String) -> mlua::Result<bool> {
	let path = runtime
		.dir_context()
		.resolve_path(runtime.session(), (&path).into(), PathResolver::WksDir, None)?;
	Ok(path.is_dir())
}

/// ## Lua Documentation
///
/// Returns the parent directory path of the specified path.
///
/// ```lua
/// -- API Signature
/// aip.path.parent(path: string): string | nil
/// ```
///
/// Gets the parent directory component of the given path string.
///
/// ### Arguments
///
/// - `path: string`: The path string.
///
/// ### Returns
///
/// - `string | nil`: Returns the parent directory path as a string. Returns `nil` if the path has no parent (e.g., ".", "/", "C:/").
///
/// ### Example
///
/// ```lua
/// print(aip.path.parent("some/path/file.txt")) -- Output: "some/path"
/// print(aip.path.parent("/root/file"))         -- Output: "/root"
/// print(aip.path.parent("."))                  -- Output: nil
/// ```
///
/// ### Error
///
/// This function does not typically error.
fn path_parent(path: String) -> mlua::Result<Option<String>> {
	match Path::new(&path).parent() {
		Some(parent) => match parent.to_str() {
			Some(parent_str) => Ok(Some(parent_str.to_string())),
			None => Ok(None),
		},
		None => Ok(None),
	}
}

/// ## Lua Documentation
///
/// Checks if a path matches one or more glob patterns.
///
/// ```lua
/// -- API Signature
/// aip.path.matches_glob(path: string | nil, globs: string | string[]): boolean | nil
/// ```
///
/// Determines whether the provided `path` matches any of the glob patterns given in `globs`. The function returns `nil` when `path` is `nil`. If `globs` is an empty string or an empty list, the result is `false`.
///
/// ### Arguments
///
/// - `path: string | nil`: The path to test. If `nil`, the function returns `nil`.
/// - `globs: string | string[]`: A single glob pattern string or a Lua list of pattern strings. Standard wildcards (`*`, `?`, `**`, `[]`) are supported.
///
/// ### Returns
///
/// - `boolean | nil`: Returns `true` when the `path` matches at least one pattern, `false` when it matches none, and `nil` when the supplied `path` was `nil`.
///
/// ### Example
///
/// ```lua
/// -- Single pattern
/// print(aip.path.matches_glob("src/main.rs", "**/*.rs"))            -- true
///
/// -- Multiple patterns
/// print(aip.path.matches_glob("README.md", {"*.md", "*.txt"}))      -- true
///
/// -- No match
/// print(aip.path.matches_glob("image.png", {"*.jpg", "*.gif"}))     -- false
///
/// -- Nil path
/// print(aip.path.matches_glob(nil, "*.rs"))                         -- nil
/// ```
///
/// ### Error
///
/// Returns an error (Lua table `{ error: string }`) if `globs` is not a string or a list of strings.
fn path_matches_glob(path: Value, globs: Value) -> mlua::Result<Value> {
	let Some(path) = into_option_string(path, "aip.path.matches_glob")? else {
		return Ok(Value::Nil);
	};

	let patterns = into_vec_of_strings(globs, "aip.path.matches_glob")?;
	if patterns.is_empty() {
		return Ok(Value::Boolean(false));
	}

	let glob_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect();
	let glob_set = get_glob_set(&glob_refs).map_err(|err| crate::Error::custom(err.to_string()))?;

	let is_match = glob_set.is_match(&path);
	Ok(Value::Boolean(is_match))
}

/// ## Lua Documentation
///
/// Sorts a list of file paths or file objects by glob priority order.
///
/// ```lua
/// -- API Signature
/// aip.path.sort_by_globs(
///   files: string[] | FileInfo[] | FileRecord[],
///   globs: string | string[],
///   options?: boolean | "start" | "end" | { end_weighted?: boolean, no_match_position?: "start" | "end" }
/// ): any[]
/// ```
///
/// Sorts the given list of file paths or file objects by the priority order defined by the `globs` patterns.
/// Items matching the first glob pattern come first, then items matching the second, and so on.
/// Items not matching any glob pattern are placed at the end by default (or start, if configured).
/// Within the same glob priority group, items are sorted by their path string.
///
/// ### Arguments
///
/// - `files: string[] | FileInfo[] | FileRecord[]`: A list of file paths (strings) or file objects
///   with a `path` property (such as `FileInfo` or `FileRecord` tables).
/// - `globs: string | string[]`: One or more glob patterns defining the sort priority. Earlier patterns
///   have higher priority.
/// - `options?: boolean | "start" | "end" | table`: Optional sort configuration:
///   - `boolean`: If `true`, enables end-weighted mode (ties broken by placing later matches at end).
///   - `"start"`: Non-matching items are placed at the start of the result.
///   - `"end"`: Non-matching items are placed at the end of the result (default).
///   - `table`: A table with optional fields:
///     - `end_weighted?: boolean`: If `true`, enables end-weighted mode.
///     - `no_match_position?: "start" | "end"`: Where to place non-matching items.
///
/// ### Returns
///
/// - `any[]`: The input list reordered by glob priority. The type of each element matches the input type.
///
/// ### Example
///
/// ```lua
/// -- Sort strings by glob priority
/// local files = {"src/main.rs", "README.md", "src/lib.rs", "Cargo.toml"}
/// local sorted = aip.path.sort_by_globs(files, {"*.toml", "*.md"})
/// -- Result: {"Cargo.toml", "README.md", "src/main.rs", "src/lib.rs"}
///
/// -- Sort with non-matches at start
/// local sorted2 = aip.path.sort_by_globs(files, {"*.toml"}, "start")
/// -- Result: {"README.md", "src/main.rs", "src/lib.rs", "Cargo.toml"}
///
/// -- Sort FileInfo objects
/// local file_infos = aip.file.list("src/", {"**/*.rs", "**/*.toml"})
/// local sorted3 = aip.path.sort_by_globs(file_infos, {"**/*.toml", "**/*.rs"})
/// ```
///
/// ### Error
///
/// Returns an error if the `files` argument is not a list, if any element cannot be resolved to a path,
/// or if the `globs` argument is invalid.
///
/// ```ts
/// {
///   error: string // Error message
/// }
/// ```
fn path_sort_by_globs(lua: &Lua, files: Value, globs: Value, options: Value) -> mlua::Result<Value> {
	// -- Parse globs
	let glob_patterns = into_vec_of_strings(globs, "aip.path.sort_by_globs")?;
	let glob_refs: Vec<&str> = glob_patterns.iter().map(|s| s.as_str()).collect();

	// -- Parse options into SortByGlobsOptions
	let sort_options = W::<SortByGlobsOptions>::from_lua(options, lua)?.0;

	// -- Build SortedItem vec from the Lua files list
	let files_table = files
		.as_table()
		.ok_or_else(|| mlua::Error::runtime("aip.path.sort_by_globs 'files' argument must be a list (table)"))?;

	struct SortedItem {
		path: SPath,
		value: Value,
	}
	impl AsRef<SPath> for SortedItem {
		fn as_ref(&self) -> &SPath {
			&self.path
		}
	}

	let mut items: Vec<SortedItem> = Vec::new();
	for pair in files_table.clone().sequence_values::<Value>() {
		let val = pair?;
		let path_str = match &val {
			Value::String(s) => s
				.to_str()
				.map(|s| s.to_string())
				.map_err(|e| mlua::Error::runtime(format!("aip.path.sort_by_globs file string error: {e}")))?,
			Value::Table(tbl) => tbl
				.get::<Value>("path")
				.ok()
				.and_then(|v| v.as_string().map(|v| v.to_string_lossy()))
				.ok_or_else(|| {
					mlua::Error::runtime("aip.path.sort_by_globs each file table must have a 'path' string field")
				})?,
			_ => {
				return Err(mlua::Error::runtime(
					"aip.path.sort_by_globs each file must be a string or a table with a 'path' field",
				));
			}
		};
		items.push(SortedItem {
			path: SPath::from(path_str),
			value: val,
		});
	}

	// -- Sort
	let sorted = sort_by_globs(items, &glob_refs, sort_options)
		.map_err(|e| mlua::Error::runtime(format!("aip.path.sort_by_globs error: {e}")))?;

	// -- Build result Lua table
	let result = lua.create_table()?;
	for (i, item) in sorted.into_iter().enumerate() {
		result.set(i + 1, item.value)?;
	}

	Ok(Value::Table(result))
}

// endregion: --- Lua Functions

// region:    --- Tests

#[cfg(test)]
mod tests {
	type Result<T> = core::result::Result<T, Box<dyn std::error::Error>>; // For tests.

	use crate::_test_support::{eval_lua, setup_lua};
	use crate::script::aip_modules::aip_path;

	#[tokio::test]
	async fn test_lua_path_exists_true() -> Result<()> {
		// -- Setup & Fixtures
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let paths = &[
			"./agent-script/agent-hello.aip",
			"agent-script/agent-hello.aip",
			"./sub-dir-a/agent-hello-2.aip",
			"sub-dir-a/agent-hello-2.aip",
			"./sub-dir-a/",
			"sub-dir-a",
			"./sub-dir-a/",
			"./sub-dir-a/../",
			"./sub-dir-a/..",
		];

		// -- Exec & Check
		for path in paths {
			let code = format!(r#"return aip.path.exists("{path}")"#);
			let res = eval_lua(&lua, &code)?;
			assert!(res.as_bool().ok_or("Result should be a bool")?, "'{path}' should exist");
		}

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_exists_false() -> Result<()> {
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let paths = &["./no file .rs", "some/no-file.md", "./s do/", "no-dir/at/all"];

		for path in paths {
			let code = format!(r#"return aip.path.exists("{path}")"#);
			let res = eval_lua(&lua, &code)?;
			assert!(
				!res.as_bool().ok_or("Result should be a bool")?,
				"'{path}' should NOT exist"
			);
		}

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_is_file_true() -> Result<()> {
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let paths = &[
			"./agent-script/agent-hello.aip",
			"agent-script/agent-hello.aip",
			"./sub-dir-a/agent-hello-2.aip",
			"sub-dir-a/agent-hello-2.aip",
			"./sub-dir-a/../agent-script/agent-hello.aip",
		];

		for path in paths {
			let code = format!(r#"return aip.path.is_file("{path}")"#);
			let res = eval_lua(&lua, &code)?;
			assert!(
				res.as_bool().ok_or("Result should be a bool")?,
				"'{path}' should be a file"
			);
		}

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_is_file_false() -> Result<()> {
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let paths = &["./no-file", "no-file.txt", "sub-dir-a/"];

		for path in paths {
			let code = format!(r#"return aip.path.is_file("{path}")"#);
			let res = eval_lua(&lua, &code)?;
			assert!(
				!res.as_bool().ok_or("Result should be a bool")?,
				"'{path}' should NOT be a file"
			);
		}

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_is_dir_true() -> Result<()> {
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let paths = &["./sub-dir-a", "sub-dir-a", "./sub-dir-a/.."];

		for path in paths {
			let code = format!(r#"return aip.path.is_dir("{path}")"#);
			let res = eval_lua(&lua, &code)?;
			assert!(
				res.as_bool().ok_or("Result should be a bool")?,
				"'{path}' should be a directory"
			);
		}

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_is_dir_false() -> Result<()> {
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let paths = &[
			"./agent-hello.aipack",
			"agent-hello.aipack",
			"./sub-dir-a/agent-hello-2.aipack",
			"./sub-dir-a/other-path",
			"nofile.txt",
			"./s rc/",
		];

		for path in paths {
			let code = format!(r#"return aip.path.is_dir("{path}")"#);
			let res = eval_lua(&lua, &code)?;
			assert!(
				!res.as_bool().ok_or("Result should be a bool")?,
				"'{path}' should NOT be a directory"
			);
		}

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_parent() -> Result<()> {
		let lua = setup_lua(aip_path::init_module, "path").await?;
		// Fixtures: (path, expected_parent)
		let paths = &[
			("./agent-hello.aipack", "."),
			("./", ""),
			(".", ""),
			("./sub-dir/file.txt", "./sub-dir"),
			("./sub-dir/file", "./sub-dir"),
			("./sub-dir/", "."),
			("./sub-dir", "."),
		];

		for (path, expected) in paths {
			let code = format!(r#"return aip.path.parent("{path}")"#);
			let res = eval_lua(&lua, &code)?;
			let result = res.as_str().ok_or("Should be a string")?;
			assert_eq!(result, *expected, "Parent mismatch for path: {path}");
		}

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_join() -> Result<()> {
		// -- Setup & Fixtures
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let data = [
			//
			("./", r#""file.txt""#, "./file.txt"),
			("./", r#"{"my-dir","file.txt"}"#, "./my-dir/file.txt"),
			("", r#"{"my-dir","file.txt"}"#, "my-dir/file.txt"),
			("", r#"{"my-dir/","//file.txt"}"#, "my-dir/file.txt"),
			("some-base/", r#""my-dir/","file.txt""#, "some-base/my-dir/file.txt"),
			("a", r#""b", "c""#, "a/bc"),
			("a/", r#""b/", "c/""#, "a/b/c/"),
			(
				"root/",
				r#"{"user", "docs"}, "projectA", {"report", "final.pdf"}"#,
				"root/user/docsprojectAreport/final.pdf",
			),
		];

		// -- Exec & Check
		for (base, args, expected) in data {
			let code = format!(r#"return aip.path.join("{base}", {args})"#);
			let res = eval_lua(&lua, &code)?;
			let res = res.as_str().ok_or("Should have returned string")?;

			assert_eq!(res, expected);
		}

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_split() -> Result<()> {
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let paths = &[
			("some/path/to_file.md", "some/path", "to_file.md"),
			("folder/file.txt", "folder", "file.txt"),
			("justafile.md", "", "justafile.md"),
			("/absolute/path/file.log", "/absolute/path", "file.log"),
			("/file_at_root", "/", "file_at_root"),
			("trailing/slash/", "trailing", "slash"),
		];

		for (path, expected_parent, expected_filename) in paths {
			let code = format!(
				r#"
                    local parent, filename = aip.path.split("{path}")
                    return {{ parent, filename }}
                "#
			);
			let res = eval_lua(&lua, &code)?;
			let res_array = res.as_array().ok_or("Expected an array from Lua function")?;
			let parent = res_array
				.first()
				.and_then(|v| v.as_str())
				.ok_or("First value should be a string")?;
			let filename = res_array
				.get(1)
				.and_then(|v| v.as_str())
				.ok_or("Second value should be a string")?;
			assert_eq!(parent, *expected_parent, "Parent mismatch for path: {path}");
			assert_eq!(filename, *expected_filename, "Filename mismatch for path: {path}");
		}

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_sort_by_globs_strings() -> Result<()> {
		// -- Setup & Fixtures
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let code = r#"
			local files = {"src/main.rs", "README.md", "src/lib.rs", "Cargo.toml"}
			return aip.path.sort_by_globs(files, {"*.toml", "*.md"})
		"#;

		// -- Exec
		let res = eval_lua(&lua, code)?;

		// -- Check
		let arr = res.as_array().ok_or("Expected array")?;
		assert_eq!(arr.len(), 4);
		assert_eq!(arr[0].as_str().ok_or("str")?, "Cargo.toml");
		assert_eq!(arr[1].as_str().ok_or("str")?, "README.md");
		// non-matches sorted by path
		assert_eq!(arr[2].as_str().ok_or("str")?, "src/lib.rs");
		assert_eq!(arr[3].as_str().ok_or("str")?, "src/main.rs");

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_sort_by_globs_no_match_start() -> Result<()> {
		// -- Setup & Fixtures
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let code = r#"
			local files = {"src/main.rs", "README.md", "src/lib.rs", "Cargo.toml"}
			return aip.path.sort_by_globs(files, {"*.toml"}, "start")
		"#;

		// -- Exec
		let res = eval_lua(&lua, code)?;

		// -- Check
		let arr = res.as_array().ok_or("Expected array")?;
		assert_eq!(arr.len(), 4);
		// non-matches at start, sorted by path
		assert_eq!(arr[0].as_str().ok_or("str")?, "README.md");
		assert_eq!(arr[1].as_str().ok_or("str")?, "src/lib.rs");
		assert_eq!(arr[2].as_str().ok_or("str")?, "src/main.rs");
		assert_eq!(arr[3].as_str().ok_or("str")?, "Cargo.toml");

		Ok(())
	}

	#[tokio::test]
	async fn test_lua_path_sort_by_globs_table_options() -> Result<()> {
		// -- Setup & Fixtures
		let lua = setup_lua(aip_path::init_module, "path").await?;
		let code = r#"
			local files = {"src/main.rs", "README.md", "Cargo.toml"}
			return aip.path.sort_by_globs(files, {"*.toml", "*.md"}, {no_match_position = "start"})
		"#;

		// -- Exec
		let res = eval_lua(&lua, code)?;

		// -- Check
		let arr = res.as_array().ok_or("Expected array")?;
		assert_eq!(arr.len(), 3);
		assert_eq!(arr[0].as_str().ok_or("str")?, "src/main.rs");
		assert_eq!(arr[1].as_str().ok_or("str")?, "Cargo.toml");
		assert_eq!(arr[2].as_str().ok_or("str")?, "README.md");

		Ok(())
	}

	// TODO: Needs to enable (code assume res is lua, but it's serde json)
	// #[tokio::test]
	// async fn test_lua_path_parse() -> Result<()> {
	// 	// -- Setup & Fixtures
	// 	let lua = setup_lua(aip_path::init_module, "path")?;
	// 	let paths = &[
	// 		("some/path/to_file.md", "some/path", "to_file.md", "to_file", "md"),
	// 		("folder/file.txt", "folder", "file.txt", "file", "txt"),
	// 		("justafile.md", "", "justafile.md", "justafile", "md"),
	// 		("/absolute/path/file.log", "/absolute/path", "file.log", "file", "log"),
	// 		("/file_at_root", "/", "file_at_root", "file_at_root", ""),
	// 		("trailing/slash/", "trailing", "slash", "slash", ""),
	// 		(".", "", ".", ".", ""),
	// 		("", "", "", "", ""),
	// 	];

	// 	// -- Exec & Check
	// 	for (path, exp_dir, exp_name, exp_stem, exp_ext) in paths {
	// 		let code = format!(r#"return aip.path.parse("{path}")"#);
	// 		let res = eval_lua(&lua, &code)?;
	// 		let table = res.as_table().ok_or_else(|| format!("Expected a table for path: {path}"))?;

	// 		// check dir
	// 		let dir = table
	// 			.get::<_, String>("dir")
	// 			.ok_or_else(|| format!("Missing 'dir' for path: {path}"))?;
	// 		assert_eq!(dir, *exp_dir, "Dir mismatch for path: {path}");

	// 		// check name
	// 		let name = table
	// 			.get::<_, String>("name")
	// 			.ok_or_else(|| format!("Missing 'name' for path: {path}"))?;
	// 		assert_eq!(name, *exp_name, "Name mismatch for path: {path}");

	// 		// check stem
	// 		let stem = table
	// 			.get::<_, String>("stem")
	// 			.ok_or_else(|| format!("Missing 'stem' for path: {path}"))?;
	// 		assert_eq!(stem, *exp_stem, "Stem mismatch for path: {path}");

	// 		// check ext
	// 		let ext = table
	// 			.get::<_, String>("ext")
	// 			.ok_or_else(|| format!("Missing 'ext' for path: {path}"))?;
	// 		assert_eq!(ext, *exp_ext, "Ext mismatch for path: {path}");

	// 		// check path
	// 		let res_path = table
	// 			.get::<_, String>("path")
	// 			.ok_or_else(|| format!("Missing 'path' for path: {path}"))?;
	// 		assert_eq!(res_path, *path, "Path mismatch for path: {path}");
	// 	}

	// 	// Check nil case
	// 	let code_nil = r#"return aip.path.parse(nil)"#;
	// 	let res_nil = eval_lua(&lua, code_nil)?;
	// 	assert!(res_nil.is_nil(), "Expected nil for nil input");

	// 	Ok(())
	// }
}

// endregion: --- Tests
