//! `WorktreeExecutor` — the coder's host-side tool executor.
//!
//! Wraps `car_engine::agent_basics` file tools plus a new host `shell` tool,
//! with three hard guarantees enforced in code (not just policy):
//!
//! 1. **Pinned cwd** — shell commands always run at the worktree root; there
//!    is no cwd parameter. Relative file-tool paths are rooted there too, and
//!    clamped against lexical escape.
//! 2. **Bounded output** — combined output is capped (tail-kept) so a noisy
//!    build can't flood the conversation or the event stream.
//! 3. **Bounded time** — wall-clock timeout per command; on expiry the whole
//!    process group is killed (Unix), not just the shell.
//!
//! Every call is checked by the coder [`InspectorChain`] first; first Deny
//! wins and the denial reason is the tool error the model sees.

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use car_engine::{agent_basics, LocalSubstrate, Substrate, ToolExecutor};
use car_policy::InspectorChain;
use serde_json::{json, Value};

use super::policy::{coder_inspector_chain, stays_under};

/// Default and ceiling for per-command wall-clock timeouts.
const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 120;
const MAX_SHELL_TIMEOUT_SECS: u64 = 600;
/// Combined stdout+stderr cap (tail kept).
const MAX_OUTPUT_BYTES: usize = 64 * 1024;

/// Keep the last `cap` bytes of `s`, on a char boundary, with a marker when
/// truncated.
pub(crate) fn tail(s: &str, cap: usize) -> String {
    if s.len() <= cap {
        return s.to_string();
    }
    let mut start = s.len() - cap;
    while !s.is_char_boundary(start) {
        start += 1;
    }
    format!("…[truncated]…{}", &s[start..])
}

pub struct WorktreeExecutor {
    worktree: PathBuf,
    inspectors: InspectorChain,
    /// Optional executor for tools this one doesn't own (e.g. the Parslee
    /// platform tools). Names listed in `delegate_defs` route here, bypassing
    /// the worktree path-clamp/inspector logic (which is file-tool specific).
    delegate: Option<Arc<dyn ToolExecutor>>,
    delegate_defs: Vec<Value>,
}

impl WorktreeExecutor {
    /// Executor for `worktree` with the standard coder inspector chain.
    pub fn new(worktree: impl Into<PathBuf>) -> Self {
        let worktree: PathBuf = worktree.into();
        // Canonicalize so lexical clamping isn't fooled by `/var` vs
        // `/private/var` style aliasing of the worktree root itself.
        let worktree = worktree.canonicalize().unwrap_or(worktree);
        let inspectors = coder_inspector_chain(&worktree);
        Self {
            worktree,
            inspectors,
            delegate: None,
            delegate_defs: Vec::new(),
        }
    }

    /// Replace the inspector chain (tests; callers wanting extra rules).
    pub fn with_chain(mut self, chain: InspectorChain) -> Self {
        self.inspectors = chain;
        self
    }

    /// Attach a delegate executor that handles the given tool `defs` (by name).
    /// Used to expose the Parslee platform tools to declarative agents without
    /// threading them through the worktree's file-tool path logic.
    pub fn with_delegate(mut self, delegate: Arc<dyn ToolExecutor>, defs: Vec<Value>) -> Self {
        self.delegate = Some(delegate);
        self.delegate_defs = defs;
        self
    }

    /// All tool defs this executor exposes: the static built-ins plus any
    /// delegate tools. Agent loops should advertise these (not the static
    /// [`Self::tool_defs`]) so delegate tools are allowlistable.
    pub fn all_tool_defs(&self) -> Vec<Value> {
        let mut defs = Self::tool_defs();
        defs.extend(self.delegate_defs.iter().cloned());
        defs
    }

    pub fn worktree(&self) -> &Path {
        &self.worktree
    }

    /// Tool definitions to expose to the model: the built-in file tools plus
    /// the coder's `shell` tool, in the `{name, description, parameters}`
    /// shape `GenerateRequest.tools` expects.
    pub fn tool_defs() -> Vec<Value> {
        let mut defs: Vec<Value> = agent_basics::entries()
            .iter()
            .map(|e| {
                json!({
                    "name": e.schema.name,
                    "description": e.schema.description,
                    "parameters": e.schema.parameters,
                })
            })
            .filter(|d| d["name"] != "calculate") // not useful for coding
            .collect();
        defs.push(json!({
            "name": "shell",
            "description": "Run a shell command at the repository root (the worktree). \
                            Use for builds, tests, and anything the file tools can't do. \
                            Output is the combined stdout+stderr tail. Some commands \
                            (git push, sudo, destructive operations outside the repo) \
                            are denied by policy.",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "Command executed via sh -c at the repository root"
                    },
                    "timeout_secs": {
                        "type": "integer",
                        "description": "Wall-clock limit (default 120, max 600)"
                    }
                },
                "required": ["command"]
            }
        }));
        defs
    }

    /// Root relative path params at the worktree and reject lexical escapes.
    /// Mirrors the param-name surface of `agent_basics` (everything keys on
    /// `path`).
    fn clamp_paths(&self, tool: &str, params: &Value) -> Result<Value, String> {
        let mut params = params.clone();
        let Some(obj) = params.as_object_mut() else {
            return Ok(params);
        };
        if let Some(Value::String(p)) = obj.get("path") {
            if !stays_under(&self.worktree, p) && matches!(tool, "write_file" | "edit_file") {
                // Reads may roam (context gathering); mutations may not.
                return Err(format!("path '{p}' resolves outside the worktree"));
            }
            if Path::new(p).is_relative() {
                // Relative paths always mean worktree-relative — even for
                // reads — never daemon-cwd-relative.
                let abs = self.worktree.join(p);
                obj.insert("path".into(), json!(abs.to_string_lossy()));
            }
        } else if tool == "list_dir" || tool == "find_files" || tool == "grep_files" {
            // These default their root to the process cwd — pin it to the
            // worktree instead.
            obj.entry("path")
                .or_insert_with(|| json!(self.worktree.to_string_lossy()));
        }
        Ok(params)
    }

    /// Run `command` via `sh -lc` at the worktree root. Returns
    /// `{exit_code, output, timed_out}` — non-zero exits are values, not
    /// errors, so the model (and contract evaluation) can read them.
    pub async fn run_shell(
        &self,
        command: &str,
        timeout_secs: Option<u64>,
    ) -> Result<Value, String> {
        if let Some(reason) = self
            .inspectors
            .check("shell", &json!({ "command": command }))
        {
            return Err(format!("denied by policy: {reason}"));
        }
        let timeout = Duration::from_secs(
            timeout_secs
                .unwrap_or(DEFAULT_SHELL_TIMEOUT_SECS)
                .clamp(1, MAX_SHELL_TIMEOUT_SECS),
        );

        let mut cmd = tokio::process::Command::new("/bin/sh");
        cmd.arg("-lc")
            .arg(command)
            .current_dir(&self.worktree)
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .kill_on_drop(true);
        #[cfg(unix)]
        cmd.process_group(0);

        let child = cmd
            .spawn()
            .map_err(|e| format!("failed to spawn shell: {e}"))?;
        #[cfg(unix)]
        let pgid = child.id();

        match tokio::time::timeout(timeout, child.wait_with_output()).await {
            Ok(Ok(out)) => {
                let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
                let stderr = String::from_utf8_lossy(&out.stderr);
                if !stderr.is_empty() {
                    if !combined.is_empty() && !combined.ends_with('\n') {
                        combined.push('\n');
                    }
                    combined.push_str(&stderr);
                }
                Ok(json!({
                    "exit_code": out.status.code().unwrap_or(-1),
                    "output": tail(&combined, MAX_OUTPUT_BYTES),
                    "timed_out": false,
                }))
            }
            Ok(Err(e)) => Err(format!("shell wait failed: {e}")),
            Err(_elapsed) => {
                // Kill the whole process group: `sh -c "sleep 999 & wait"`
                // style trees must not outlive the timeout. kill_on_drop has
                // already reaped the shell itself; this sweeps descendants.
                #[cfg(unix)]
                if let Some(pid) = pgid {
                    unsafe {
                        libc::killpg(pid as i32, libc::SIGKILL);
                    }
                }
                Ok(json!({
                    "exit_code": Value::Null,
                    "output": format!("command timed out after {}s and was killed", timeout.as_secs()),
                    "timed_out": true,
                }))
            }
        }
    }
}

#[async_trait]
impl ToolExecutor for WorktreeExecutor {
    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
        if tool == "shell" {
            let command = params
                .get("command")
                .and_then(Value::as_str)
                .ok_or("missing 'command' parameter")?;
            let timeout_secs = params.get("timeout_secs").and_then(Value::as_u64);
            return self.run_shell(command, timeout_secs).await;
        }

        // Delegate-owned tools (e.g. Parslee platform tools) carry no worktree
        // paths — route them straight through, skipping clamp/inspector.
        if self.delegate_defs.iter().any(|d| d["name"] == tool) {
            if let Some(delegate) = &self.delegate {
                return delegate.execute(tool, params).await;
            }
        }

        let clamped = self.clamp_paths(tool, params)?;
        if let Some(reason) = self.inspectors.check(tool, &clamped) {
            return Err(format!("denied by policy: {reason}"));
        }
        // Paths are clamped to the worktree (absolute) above; LocalSubstrate
        // passes absolute paths through verbatim, so behavior is unchanged.
        let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
        match agent_basics::execute(&substrate, tool, &clamped).await {
            Some(result) => result,
            None => Err(format!("unknown tool: {tool}")),
        }
    }
}

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

    fn executor() -> (tempfile::TempDir, WorktreeExecutor) {
        let dir = tempfile::tempdir().unwrap();
        let exec = WorktreeExecutor::new(dir.path());
        (dir, exec)
    }

    #[tokio::test]
    async fn shell_runs_at_worktree_root() {
        let (dir, exec) = executor();
        let out = exec.run_shell("pwd", Some(10)).await.unwrap();
        let cwd = out["output"].as_str().unwrap().trim();
        assert_eq!(
            PathBuf::from(cwd).canonicalize().unwrap(),
            dir.path().canonicalize().unwrap()
        );
        assert_eq!(out["exit_code"], 0);
    }

    #[tokio::test]
    async fn shell_reports_nonzero_exit_as_value() {
        let (_dir, exec) = executor();
        let out = exec.run_shell("exit 3", Some(10)).await.unwrap();
        assert_eq!(out["exit_code"], 3);
        assert_eq!(out["timed_out"], false);
    }

    #[tokio::test]
    async fn shell_captures_stderr() {
        let (_dir, exec) = executor();
        let out = exec
            .run_shell("echo to-out; echo to-err 1>&2", Some(10))
            .await
            .unwrap();
        let text = out["output"].as_str().unwrap();
        assert!(text.contains("to-out") && text.contains("to-err"));
    }

    #[tokio::test]
    async fn shell_timeout_kills_and_reports() {
        let (_dir, exec) = executor();
        let started = std::time::Instant::now();
        let out = exec.run_shell("sleep 30", Some(1)).await.unwrap();
        assert!(
            started.elapsed() < Duration::from_secs(10),
            "did not wait out the sleep"
        );
        assert_eq!(out["timed_out"], true);
        assert!(out["exit_code"].is_null());
    }

    #[tokio::test]
    async fn shell_denied_by_policy() {
        let (_dir, exec) = executor();
        let err = exec
            .run_shell("git push origin main", Some(5))
            .await
            .unwrap_err();
        assert!(err.contains("denied by policy"), "{err}");
    }

    #[tokio::test]
    async fn relative_file_writes_land_in_worktree() {
        let (dir, exec) = executor();
        exec.execute(
            "write_file",
            &json!({"path": "sub/out.txt", "content": "hi"}),
        )
        .await
        .unwrap();
        assert_eq!(
            std::fs::read_to_string(dir.path().join("sub/out.txt")).unwrap(),
            "hi"
        );
    }

    #[tokio::test]
    async fn escaping_writes_are_rejected_in_code() {
        let (_dir, exec) = executor();
        let err = exec
            .execute(
                "write_file",
                &json!({"path": "../escape.txt", "content": "x"}),
            )
            .await
            .unwrap_err();
        assert!(err.contains("outside the worktree"), "{err}");

        let err = exec
            .execute(
                "write_file",
                &json!({"path": "/tmp/abs-escape.txt", "content": "x"}),
            )
            .await
            .unwrap_err();
        assert!(err.contains("outside the worktree"), "{err}");
    }

    #[tokio::test]
    async fn list_dir_defaults_to_worktree_not_process_cwd() {
        let (dir, exec) = executor();
        std::fs::write(dir.path().join("marker.txt"), "x").unwrap();
        let out = exec.execute("list_dir", &json!({})).await.unwrap();
        assert!(
            out.to_string().contains("marker.txt"),
            "expected worktree listing, got: {out}"
        );
    }

    #[tokio::test]
    async fn output_is_tail_capped() {
        let (_dir, exec) = executor();
        // ~200KB of output → capped to the 64KB tail.
        let out = exec
            .run_shell("i=0; while [ $i -lt 5000 ]; do echo 'line of output 40 bytes long....'; i=$((i+1)); done", Some(30))
            .await
            .unwrap();
        let text = out["output"].as_str().unwrap();
        assert!(text.len() <= MAX_OUTPUT_BYTES + 32, "len={}", text.len());
        assert!(text.starts_with("…[truncated]…"));
    }

    #[tokio::test]
    async fn unknown_tool_errors() {
        let (_dir, exec) = executor();
        assert!(exec.execute("teleport", &json!({})).await.is_err());
    }

    struct StubDelegate;
    #[async_trait]
    impl ToolExecutor for StubDelegate {
        async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
            Ok(json!({ "via": "delegate", "tool": tool, "echo": params.clone() }))
        }
    }

    #[tokio::test]
    async fn delegate_tool_routes_through_delegate_and_is_advertised() {
        let dir = tempfile::tempdir().unwrap();
        let defs = vec![json!({
            "name": "ext_tool",
            "description": "external",
            "parameters": { "type": "object", "properties": {} }
        })];
        let exec = WorktreeExecutor::new(dir.path()).with_delegate(Arc::new(StubDelegate), defs);

        // all_tool_defs surfaces the delegate tool alongside the built-ins…
        let names: Vec<String> = exec
            .all_tool_defs()
            .iter()
            .filter_map(|d| d["name"].as_str().map(String::from))
            .collect();
        assert!(names.iter().any(|n| n == "ext_tool"));
        assert!(names.iter().any(|n| n == "read_file")); // built-ins still present

        // …and execute() routes it to the delegate (no worktree clamp/inspector).
        let out = exec.execute("ext_tool", &json!({ "x": 1 })).await.unwrap();
        assert_eq!(out["via"], "delegate");
        assert_eq!(out["tool"], "ext_tool");
        assert_eq!(out["echo"]["x"], 1);

        // Tools the delegate doesn't own still fall through to "unknown".
        assert!(exec.execute("teleport", &json!({})).await.is_err());
    }

    #[test]
    fn tail_respects_char_boundaries() {
        let s = "ééééé"; // 2 bytes each
        let t = tail(s, 3);
        assert!(t.ends_with('é'));
    }
}
