//! Rhai scripting integration for lightweight custom functions.
//!
//! Users can write `.rhai` script files that define functions.  This module
//! compiles those scripts in a sandboxed engine and wraps each
//! script-defined function as a [`CalcPadPlugin`] for seamless integration
//! with the plugin registry.
//!
//! # Current status
//!
//! The Rhai integration is **stubbed**.  When the `rhai` crate is added as
//! a dependency (with the `sync` feature) the stubs in this file should be
//! replaced with the real Rhai engine calls.  The public API and types are
//! already designed and tested; only the compile/call internals are pending.
//!
//! To enable in the future, add to `Cargo.toml`:
//!
//! ```toml
//! rhai = { version = "1", features = ["sync"] }
//! ```
//!
//! # Sandbox guarantees (when fully enabled)
//!
//! - `Engine::new_raw()` — no standard library, no I/O
//! - `eval` symbol disabled
//! - Operation limit: 1 000 000
//! - Call-depth limit: 64
//! - Expression-depth limit: 64

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use super::api::{
    CalcPadPlugin, PluginFn, PluginFunctionRegistry, PluginUnitRegistry,
    PluginValue, PluginVariableRegistry,
};

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// Error from loading or compiling a Rhai script.
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptError {
    pub file: PathBuf,
    pub line: Option<usize>,
    pub message: String,
}

impl std::fmt::Display for ScriptError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if let Some(line) = self.line {
            write!(f, "{}: line {}: {}", self.file.display(), line, self.message)
        } else {
            write!(f, "{}: {}", self.file.display(), self.message)
        }
    }
}

impl std::error::Error for ScriptError {}

// ---------------------------------------------------------------------------
// Built-in name reservation
// ---------------------------------------------------------------------------

const BUILTIN_NAMES: &[&str] = &[
    "sin", "cos", "tan", "asin", "acos", "atan",
    "sinh", "cosh", "tanh",
    "sqrt", "abs", "ceil", "floor", "round",
    "ln", "log", "log2", "log10",
    "exp", "pow", "min", "max",
    "factorial", "nPr", "nCr",
    "gcd", "lcm",
];

fn is_builtin_name(name: &str) -> bool {
    BUILTIN_NAMES.contains(&name)
}

// ---------------------------------------------------------------------------
// ScriptPlugin
// ---------------------------------------------------------------------------

/// A plugin whose functions were loaded from `.rhai` script files.
///
/// This wraps compiled script functions as standard [`CalcPadPlugin`]
/// contributions so they integrate seamlessly with the
/// [`PluginRegistry`](super::registry::PluginRegistry).
pub struct ScriptPlugin {
    name: String,
    functions: HashMap<String, PluginFn>,
}

impl ScriptPlugin {
    /// Create a script plugin directly from pre-built function closures.
    ///
    /// This is the primary constructor used by [`load_scripts_from_dir`] and
    /// is also useful for testing.
    pub fn new(name: impl Into<String>, functions: HashMap<String, PluginFn>) -> Self {
        Self {
            name: name.into(),
            functions,
        }
    }

    /// Number of functions this script plugin provides.
    pub fn function_count(&self) -> usize {
        self.functions.len()
    }

    /// Names of all functions provided by this plugin.
    pub fn function_names(&self) -> Vec<&str> {
        let mut names: Vec<&str> = self.functions.keys().map(|s| s.as_str()).collect();
        names.sort();
        names
    }
}

impl CalcPadPlugin for ScriptPlugin {
    fn name(&self) -> &str {
        &self.name
    }

    fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
        for (name, func) in &self.functions {
            let f = Arc::clone(func);
            reg.register(name.clone(), move |args: &[PluginValue]| f(args));
        }
    }

    // Scripts currently only provide functions (units and variables would
    // require a richer scripting protocol).
    fn register_units(&self, _reg: &mut PluginUnitRegistry) {}
    fn register_variables(&self, _reg: &mut PluginVariableRegistry) {}
}

// ---------------------------------------------------------------------------
// Load result
// ---------------------------------------------------------------------------

/// Result of loading `.rhai` scripts from a directory.
pub struct ScriptLoadResult {
    /// The merged plugin, or `None` if no functions were loaded.
    pub plugin: Option<ScriptPlugin>,
    /// Errors from scripts that failed to parse / execute.
    pub errors: Vec<ScriptError>,
    /// Non-fatal warnings (e.g., built-in name conflicts).
    pub warnings: Vec<String>,
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Load all `.rhai` scripts from `dir`, compile them, and return a
/// [`ScriptLoadResult`].
///
/// Scripts that fail to parse are reported in `errors` but do not prevent
/// other scripts from loading.  Functions whose names conflict with
/// built-in engine names are skipped (reported in `warnings`).
///
/// **Current implementation is stubbed** — it scans for `.rhai` files to
/// validate the directory but does not compile them (Rhai is not yet a
/// dependency).  Each discovered function-definition line is noted but
/// returns a stub error at call time.
pub fn load_scripts_from_dir(dir: &Path) -> ScriptLoadResult {
    let mut errors = Vec::new();
    let mut warnings = Vec::new();
    let mut functions: HashMap<String, PluginFn> = HashMap::new();

    if !dir.exists() || !dir.is_dir() {
        return ScriptLoadResult {
            plugin: None,
            errors,
            warnings,
        };
    }

    // Collect .rhai files
    let mut rhai_files: Vec<PathBuf> = match std::fs::read_dir(dir) {
        Ok(entries) => entries
            .filter_map(|e| e.ok())
            .map(|e| e.path())
            .filter(|p| p.extension().map_or(false, |ext| ext == "rhai"))
            .collect(),
        Err(e) => {
            errors.push(ScriptError {
                file: dir.to_path_buf(),
                line: None,
                message: format!("failed to read directory: {}", e),
            });
            return ScriptLoadResult {
                plugin: None,
                errors,
                warnings,
            };
        }
    };
    rhai_files.sort();

    if rhai_files.is_empty() {
        return ScriptLoadResult {
            plugin: None,
            errors,
            warnings,
        };
    }

    // Stub: parse files looking for `fn <name>(<args>)` patterns.
    // Real implementation would use `rhai::Engine::compile()`.
    for path in &rhai_files {
        match std::fs::read_to_string(path) {
            Ok(source) => {
                let extracted = extract_stub_functions(&source);
                for (name, param_count) in extracted {
                    if is_builtin_name(&name) {
                        warnings.push(format!(
                            "script '{}': function '{}' conflicts with built-in -- skipped",
                            path.file_name().unwrap_or_default().to_string_lossy(),
                            name,
                        ));
                        continue;
                    }
                    let fname = name.clone();
                    let pc = param_count;
                    let plugin_fn: PluginFn = Arc::new(move |args: &[PluginValue]| {
                        if args.len() != pc {
                            return Err(format!(
                                "{}() expects {} argument{}, got {}",
                                fname,
                                pc,
                                if pc == 1 { "" } else { "s" },
                                args.len(),
                            ));
                        }
                        // Stub: real impl would evaluate the AST via Rhai engine.
                        Err(format!(
                            "{}(): Rhai scripting engine is not yet enabled",
                            fname,
                        ))
                    });
                    functions.insert(name, plugin_fn);
                }
            }
            Err(e) => {
                errors.push(ScriptError {
                    file: path.clone(),
                    line: None,
                    message: format!("failed to read file: {}", e),
                });
            }
        }
    }

    let plugin = if functions.is_empty() {
        None
    } else {
        Some(ScriptPlugin::new("rhai_scripts", functions))
    };

    ScriptLoadResult {
        plugin,
        errors,
        warnings,
    }
}

/// Naive extraction of `fn name(a, b, c)` definitions from Rhai source.
///
/// This is a stub parser that extracts function signatures without a full
/// Rhai compile step.  It finds lines matching `fn <ident>(<params>)` and
/// returns `(name, param_count)` pairs.
fn extract_stub_functions(source: &str) -> Vec<(String, usize)> {
    let mut results = Vec::new();
    for line in source.lines() {
        let trimmed = line.trim();
        if !trimmed.starts_with("fn ") {
            continue;
        }
        // "fn double(x) { ... }" -> extract "double" and count params
        let rest = trimmed[3..].trim();
        if let Some(paren_open) = rest.find('(') {
            let name = rest[..paren_open].trim().to_string();
            if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
                continue;
            }
            if let Some(paren_close) = rest.find(')') {
                let params_str = rest[paren_open + 1..paren_close].trim();
                let param_count = if params_str.is_empty() {
                    0
                } else {
                    params_str.split(',').count()
                };
                results.push((name, param_count));
            }
        }
    }
    results
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    /// Helper to create a temporary directory for test scripts.
    struct TempDir {
        path: PathBuf,
    }

    impl TempDir {
        fn new(suffix: &str) -> Self {
            let path = std::env::temp_dir().join(format!(
                "calcpad_scripting_test_{}_{}", std::process::id(), suffix
            ));
            let _ = fs::remove_dir_all(&path);
            fs::create_dir_all(&path).unwrap();
            Self { path }
        }

        fn write_file(&self, name: &str, content: &str) {
            fs::write(self.path.join(name), content).unwrap();
        }

        fn path(&self) -> &Path {
            &self.path
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.path);
        }
    }

    // -- extract_stub_functions tests --

    #[test]
    fn test_extract_no_functions() {
        let fns = extract_stub_functions("let x = 5;\nlet y = 10;");
        assert!(fns.is_empty());
    }

    #[test]
    fn test_extract_single_function() {
        let fns = extract_stub_functions("fn double(x) { x * 2.0 }");
        assert_eq!(fns.len(), 1);
        assert_eq!(fns[0].0, "double");
        assert_eq!(fns[0].1, 1);
    }

    #[test]
    fn test_extract_multiple_functions() {
        let src = r#"
            fn double(x) { x * 2.0 }
            fn add(a, b) { a + b }
            fn pi() { 3.14159 }
        "#;
        let fns = extract_stub_functions(src);
        assert_eq!(fns.len(), 3);
        assert_eq!(fns[0], ("double".into(), 1));
        assert_eq!(fns[1], ("add".into(), 2));
        assert_eq!(fns[2], ("pi".into(), 0));
    }

    #[test]
    fn test_extract_ignores_non_fn_lines() {
        let src = "// fn fake(x) {}\nfn real(x) { x }";
        let fns = extract_stub_functions(src);
        assert_eq!(fns.len(), 1);
        assert_eq!(fns[0].0, "real");
    }

    // -- load_scripts_from_dir tests --

    #[test]
    fn test_load_empty_dir() {
        let tmp = TempDir::new("empty");
        let result = load_scripts_from_dir(tmp.path());
        assert!(result.plugin.is_none());
        assert!(result.errors.is_empty());
    }

    #[test]
    fn test_load_nonexistent_dir() {
        let result = load_scripts_from_dir(Path::new("/tmp/nonexistent_calcpad_99999"));
        assert!(result.plugin.is_none());
        assert!(result.errors.is_empty());
    }

    #[test]
    fn test_load_simple_function() {
        let tmp = TempDir::new("simple");
        tmp.write_file("math.rhai", "fn double(x) { x * 2.0 }");

        let result = load_scripts_from_dir(tmp.path());
        assert!(result.errors.is_empty());
        assert!(result.warnings.is_empty());
        let plugin = result.plugin.unwrap();
        assert_eq!(plugin.function_count(), 1);
        assert_eq!(plugin.function_names(), vec!["double"]);
    }

    #[test]
    fn test_load_multiple_functions_one_file() {
        let tmp = TempDir::new("multi_fn");
        tmp.write_file("funcs.rhai", r#"
            fn double(x) { x * 2.0 }
            fn triple(x) { x * 3.0 }
            fn add(a, b) { a + b }
        "#);

        let result = load_scripts_from_dir(tmp.path());
        assert!(result.errors.is_empty());
        let plugin = result.plugin.unwrap();
        assert_eq!(plugin.function_count(), 3);
    }

    #[test]
    fn test_load_multiple_files() {
        let tmp = TempDir::new("multi_file");
        tmp.write_file("a.rhai", "fn from_a(x) { x + 1.0 }");
        tmp.write_file("b.rhai", "fn from_b(x) { x + 2.0 }");

        let result = load_scripts_from_dir(tmp.path());
        assert!(result.errors.is_empty());
        let plugin = result.plugin.unwrap();
        assert_eq!(plugin.function_count(), 2);
    }

    #[test]
    fn test_non_rhai_files_ignored() {
        let tmp = TempDir::new("ignore");
        tmp.write_file("readme.txt", "not a script");
        tmp.write_file("script.lua", "-- not rhai");
        tmp.write_file("actual.rhai", "fn real_fn(x) { x }");

        let result = load_scripts_from_dir(tmp.path());
        assert!(result.errors.is_empty());
        let plugin = result.plugin.unwrap();
        assert_eq!(plugin.function_count(), 1);
        assert_eq!(plugin.function_names(), vec!["real_fn"]);
    }

    #[test]
    fn test_builtin_conflict_produces_warning() {
        let tmp = TempDir::new("conflict");
        tmp.write_file("math.rhai", r#"
            fn sqrt(x) { x }
            fn my_func(x) { x * 2.0 }
        "#);

        let result = load_scripts_from_dir(tmp.path());
        let plugin = result.plugin.unwrap();
        // sqrt should be skipped
        assert_eq!(plugin.function_count(), 1);
        assert_eq!(plugin.function_names(), vec!["my_func"]);
        // warning about sqrt
        assert_eq!(result.warnings.len(), 1);
        assert!(result.warnings[0].contains("sqrt"));
        assert!(result.warnings[0].contains("built-in"));
    }

    #[test]
    fn test_multiple_builtin_conflicts() {
        let tmp = TempDir::new("multi_conflict");
        tmp.write_file("bad.rhai", r#"
            fn sin(x) { x }
            fn cos(x) { x }
            fn safe_fn(x) { x }
        "#);

        let result = load_scripts_from_dir(tmp.path());
        assert_eq!(result.warnings.len(), 2);
        let plugin = result.plugin.unwrap();
        assert_eq!(plugin.function_count(), 1);
        assert_eq!(plugin.function_names(), vec!["safe_fn"]);
    }

    #[test]
    fn test_stub_function_arity_check() {
        let tmp = TempDir::new("arity");
        tmp.write_file("math.rhai", "fn double(x) { x * 2.0 }");

        let result = load_scripts_from_dir(tmp.path());
        let plugin = result.plugin.unwrap();

        // Register into a PluginFunctionRegistry so we can call it.
        let mut freg = PluginFunctionRegistry::new();
        plugin.register_functions(&mut freg);

        let func = freg.functions.get("double").unwrap();
        // Wrong arity should produce an error.
        let err = func(&[PluginValue::new(1.0), PluginValue::new(2.0)]).unwrap_err();
        assert!(err.contains("expects 1 argument"));
    }

    #[test]
    fn test_stub_function_returns_engine_not_enabled() {
        let tmp = TempDir::new("stub_err");
        tmp.write_file("math.rhai", "fn double(x) { x * 2.0 }");

        let result = load_scripts_from_dir(tmp.path());
        let plugin = result.plugin.unwrap();

        let mut freg = PluginFunctionRegistry::new();
        plugin.register_functions(&mut freg);

        let func = freg.functions.get("double").unwrap();
        // Correct arity but stub should say engine is not enabled.
        let err = func(&[PluginValue::new(5.0)]).unwrap_err();
        assert!(err.contains("not yet enabled"));
    }

    #[test]
    fn test_zero_arg_function_extracted() {
        let fns = extract_stub_functions("fn pi() { 3.14159 }");
        assert_eq!(fns.len(), 1);
        assert_eq!(fns[0].0, "pi");
        assert_eq!(fns[0].1, 0);
    }

    #[test]
    fn test_script_plugin_implements_calcpad_plugin() {
        let functions: HashMap<String, PluginFn> = HashMap::new();
        let plugin = ScriptPlugin::new("test_scripts", functions);
        // Verify it satisfies the trait
        let p: &dyn CalcPadPlugin = &plugin;
        assert_eq!(p.name(), "test_scripts");
    }

    #[test]
    fn test_script_error_display_with_line() {
        let e = ScriptError {
            file: PathBuf::from("foo.rhai"),
            line: Some(42),
            message: "unexpected token".into(),
        };
        assert_eq!(e.to_string(), "foo.rhai: line 42: unexpected token");
    }

    #[test]
    fn test_script_error_display_without_line() {
        let e = ScriptError {
            file: PathBuf::from("bar.rhai"),
            line: None,
            message: "file not found".into(),
        };
        assert_eq!(e.to_string(), "bar.rhai: file not found");
    }
}
