//! Foreground shell execution: `bash -c`, combined stdout/stderr, hard timeout
//! with process-group kill, gemini-cli-style tail truncation.

use std::path::Path;
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio_util::sync::CancellationToken;

/// Keep the model's context lean: large outputs keep the last N lines.
pub const TRUNCATE_THRESHOLD: usize = 120_000; // chars
pub const TRUNCATE_KEEP_LINES: usize = 400;
pub const MAX_LINE_WIDTH: usize = 2000;
pub const MAX_SINGLE_LINE_KEEP: usize = 8000;

#[derive(Debug)]
pub struct ShellOutcome {
    pub output: String,
    pub exit_code: i32,
    pub duration_ms: u128,
    pub timed_out: bool,
    pub cancelled: bool,
}

pub fn truncate_output(content: &str) -> String {
    if content.len() <= TRUNCATE_THRESHOLD {
        return content.to_string();
    }
    let lines: Vec<&str> = content.split('\n').collect();
    if lines.len() > 1 {
        let tail = &lines[lines.len().saturating_sub(TRUNCATE_KEEP_LINES)..];
        let processed: Vec<String> = tail
            .iter()
            .map(|l| {
                if l.len() > MAX_LINE_WIDTH {
                    let cut: String = l.chars().take(MAX_LINE_WIDTH).collect();
                    format!("{cut}... [LINE WIDTH TRUNCATED]")
                } else {
                    (*l).to_string()
                }
            })
            .collect();
        format!(
            "Output too large. Showing the last {} of {} lines.\n...\n{}",
            processed.len(),
            lines.len(),
            processed.join("\n")
        )
    } else {
        let tail: String = content
            .chars()
            .rev()
            .take(MAX_SINGLE_LINE_KEEP)
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect();
        format!(
            "Output too large. Showing the last {MAX_SINGLE_LINE_KEEP} characters.\n...{tail}"
        )
    }
}

/// Run a command in the foreground. The child gets its own process group so a
/// timeout/cancel kills the whole tree.
pub async fn run_shell(
    command: &str,
    cwd: &Path,
    timeout_ms: u64,
    cancel: &CancellationToken,
) -> ShellOutcome {
    let start = Instant::now();
    let mut cmd = Command::new("bash");
    cmd.arg("-c")
        .arg(command)
        .current_dir(cwd)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true);
    // New process group so we can kill the whole tree on timeout (Unix only).
    #[cfg(unix)]
    unsafe {
        cmd.pre_exec(|| {
            libc::setpgid(0, 0);
            Ok(())
        });
    }

    let mut child = match cmd.spawn() {
        Ok(c) => c,
        Err(e) => {
            return ShellOutcome {
                output: format!("Failed to spawn shell: {e}"),
                exit_code: 127,
                duration_ms: start.elapsed().as_millis(),
                timed_out: false,
                cancelled: false,
            }
        }
    };

    let pid = child.id().map(|p| p as i32);
    let mut stdout = child.stdout.take().expect("piped stdout");
    let mut stderr = child.stderr.take().expect("piped stderr");

    let reader = async {
        let mut out_buf = Vec::new();
        let mut err_buf = Vec::new();
        let _ = tokio::join!(stdout.read_to_end(&mut out_buf), stderr.read_to_end(&mut err_buf));
        (out_buf, err_buf)
    };

    let timeout = if timeout_ms == 0 {
        Duration::from_secs(86_400)
    } else {
        Duration::from_millis(timeout_ms)
    };

    let mut timed_out = false;
    let mut cancelled = false;
    let (out_buf, err_buf, status) = tokio::select! {
        (bufs, status) = async {
            let bufs = reader.await;
            let status = child.wait().await;
            (bufs, status)
        } => (bufs.0, bufs.1, status.ok()),
        _ = tokio::time::sleep(timeout) => {
            timed_out = true;
            kill_group(pid, &mut child).await;
            (Vec::new(), Vec::new(), None)
        }
        _ = cancel.cancelled() => {
            cancelled = true;
            kill_group(pid, &mut child).await;
            (Vec::new(), Vec::new(), None)
        }
    };

    let stdout_s = String::from_utf8_lossy(&out_buf);
    let stderr_s = String::from_utf8_lossy(&err_buf);
    let mut combined = stdout_s.to_string();
    if !stderr_s.is_empty() {
        if !combined.is_empty() {
            combined.push('\n');
        }
        combined.push_str(&stderr_s);
    }

    let exit_code = if timed_out || cancelled {
        124
    } else {
        status.and_then(|s| s.code()).unwrap_or(-1)
    };

    ShellOutcome {
        output: truncate_output(&combined),
        exit_code,
        duration_ms: start.elapsed().as_millis(),
        timed_out,
        cancelled,
    }
}

async fn kill_group(pid: Option<i32>, child: &mut tokio::process::Child) {
    #[cfg(unix)]
    if let Some(pid) = pid {
        unsafe {
            libc::killpg(pid, libc::SIGTERM);
        }
        tokio::time::sleep(Duration::from_millis(300)).await;
        unsafe {
            libc::killpg(pid, libc::SIGKILL);
        }
    }
    // On Windows there is no process-group kill; terminate the child directly.
    #[cfg(not(unix))]
    let _ = pid;
    let _ = child.kill().await;
}

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

    #[tokio::test]
    async fn runs_simple_command() {
        let cancel = CancellationToken::new();
        let out = run_shell("echo hello && echo err >&2", Path::new("/tmp"), 10_000, &cancel).await;
        assert_eq!(out.exit_code, 0);
        assert!(out.output.contains("hello"));
        assert!(out.output.contains("err"));
    }

    #[tokio::test]
    async fn nonzero_exit_code() {
        let cancel = CancellationToken::new();
        let out = run_shell("exit 3", Path::new("/tmp"), 10_000, &cancel).await;
        assert_eq!(out.exit_code, 3);
    }

    #[tokio::test]
    async fn timeout_kills_process_tree() {
        let cancel = CancellationToken::new();
        let start = Instant::now();
        let out = run_shell("sleep 30", Path::new("/tmp"), 500, &cancel).await;
        assert!(out.timed_out);
        assert!(start.elapsed() < Duration::from_secs(5));
    }

    #[test]
    fn truncates_many_lines() {
        let big = (0..10_000).map(|i| format!("line {i} {}", "x".repeat(20))).collect::<Vec<_>>().join("\n");
        let t = truncate_output(&big);
        assert!(t.contains("Output too large"));
        assert!(t.contains("line 9999"));
        assert!(!t.contains("line 100\n"));
    }
}
