use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;

use crate::product::agent::rollout::list::find_thread_path_by_id_str;
use crate::product::agent::shell::Shell;
use crate::product::agent::shell::ShellType;
use crate::product::agent::shell::get_shell;
use crate::product::otel::OtelManager;
use crate::product::protocol::ThreadId;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use tokio::fs;
use tokio::io::AsyncReadExt;
use tokio::process::Child;
use tokio::process::Command;
use tokio::sync::watch;
use tokio::time::sleep;
use tracing::Instrument;
use tracing::info_span;

#[cfg(unix)]
use crate::product::utils_pty::process_group::kill_child_process_group;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ShellSnapshot {
    pub path: PathBuf,
}

const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7 days retention.
const SNAPSHOT_DIR: &str = "shell_snapshots";
const EXCLUDED_EXPORT_VARS: &[&str] = &["PWD", "OLDPWD"];

impl ShellSnapshot {
    pub fn start_snapshotting(
        lha_home: PathBuf,
        session_id: ThreadId,
        shell: &mut Shell,
        otel_manager: OtelManager,
    ) {
        let (shell_snapshot_tx, shell_snapshot_rx) = watch::channel(None);
        shell.shell_snapshot = shell_snapshot_rx;

        let snapshot_shell = shell.clone();
        let snapshot_session_id = session_id;
        let snapshot_span = info_span!("shell_snapshot", thread_id = %snapshot_session_id);
        tokio::spawn(
            async move {
                let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]);
                let snapshot =
                    ShellSnapshot::try_new(&lha_home, snapshot_session_id, &snapshot_shell)
                        .await
                        .map(Arc::new);
                let success = if snapshot.is_some() { "true" } else { "false" };
                let _ = timer.map(|timer| timer.record(&[("success", success)]));
                otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]);
                let _ = shell_snapshot_tx.send(snapshot);
            }
            .instrument(snapshot_span),
        );
    }

    async fn try_new(lha_home: &Path, session_id: ThreadId, shell: &Shell) -> Option<Self> {
        // File to store the snapshot
        let extension = match shell.shell_type {
            ShellType::PowerShell => "ps1",
            _ => "sh",
        };
        let path = lha_home
            .join(SNAPSHOT_DIR)
            .join(format!("{session_id}.{extension}"));

        // Clean the (unlikely) leaked snapshot files.
        let lha_home = lha_home.to_path_buf();
        let cleanup_session_id = session_id;
        tokio::spawn(async move {
            if let Err(err) = cleanup_stale_snapshots(&lha_home, cleanup_session_id).await {
                tracing::warn!("Failed to clean up shell snapshots: {err:?}");
            }
        });

        // Make the new snapshot.
        let snapshot = match write_shell_snapshot(shell.shell_type.clone(), &path).await {
            Ok(path) => {
                tracing::info!("Shell snapshot successfully created: {}", path.display());
                Some(Self { path })
            }
            Err(err) => {
                tracing::warn!(
                    "Failed to create shell snapshot for {}: {err:?}",
                    shell.name()
                );
                None
            }
        };

        if let Some(snapshot) = snapshot.as_ref()
            && let Err(err) = validate_snapshot(shell, &snapshot.path).await
        {
            tracing::error!("Shell snapshot validation failed: {err:?}");
            return None;
        }

        snapshot
    }
}

impl Drop for ShellSnapshot {
    fn drop(&mut self) {
        if let Err(err) = std::fs::remove_file(&self.path) {
            tracing::warn!(
                "Failed to delete shell snapshot at {:?}: {err:?}",
                self.path
            );
        }
    }
}

async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result<PathBuf> {
    if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd {
        bail!("Shell snapshot not supported yet for {shell_type:?}");
    }
    let shell = get_shell(shell_type.clone(), None)
        .with_context(|| format!("No available shell for {shell_type:?}"))?;

    let raw_snapshot = capture_snapshot(&shell).await?;
    let snapshot = strip_snapshot_preamble(&raw_snapshot)?;

    if let Some(parent) = output_path.parent() {
        let parent_display = parent.display();
        fs::create_dir_all(parent)
            .await
            .with_context(|| format!("Failed to create snapshot parent {parent_display}"))?;
    }

    let snapshot_path = output_path.display();
    fs::write(output_path, snapshot)
        .await
        .with_context(|| format!("Failed to write snapshot to {snapshot_path}"))?;

    Ok(output_path.to_path_buf())
}

async fn capture_snapshot(shell: &Shell) -> Result<String> {
    let shell_type = shell.shell_type.clone();
    match shell_type {
        ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script()).await,
        ShellType::Bash => run_shell_script(shell, &bash_snapshot_script()).await,
        ShellType::Sh => run_shell_script(shell, &sh_snapshot_script()).await,
        ShellType::PowerShell => run_shell_script(shell, powershell_snapshot_script()).await,
        ShellType::Cmd => bail!("Shell snapshotting is not yet supported for {shell_type:?}"),
    }
}

fn strip_snapshot_preamble(snapshot: &str) -> Result<String> {
    let marker = "# Snapshot file";
    let Some(start) = snapshot.find(marker) else {
        bail!("Snapshot output missing marker {marker}");
    };

    Ok(snapshot[start..].to_string())
}

async fn validate_snapshot(shell: &Shell, snapshot_path: &Path) -> Result<()> {
    let snapshot_path_display = snapshot_path.display();
    let script = format!("set -e; . \"{snapshot_path_display}\"");
    run_script_with_timeout(shell, &script, SNAPSHOT_TIMEOUT, false)
        .await
        .map(|_| ())
}

async fn run_shell_script(shell: &Shell, script: &str) -> Result<String> {
    run_script_with_timeout(shell, script, SNAPSHOT_TIMEOUT, true).await
}

async fn run_script_with_timeout(
    shell: &Shell,
    script: &str,
    snapshot_timeout: Duration,
    use_login_shell: bool,
) -> Result<String> {
    let args = shell.derive_exec_args(script, use_login_shell);
    let shell_name = shell.name();

    // Handler is kept as guard to control the drop. The `mut` pattern is required because .args()
    // returns a ref of handler.
    let mut handler = Command::new(&args[0]);
    handler.args(&args[1..]);
    handler.stdin(Stdio::null());
    #[cfg(unix)]
    unsafe {
        handler.pre_exec(|| {
            crate::product::utils_pty::process_group::detach_from_tty()?;
            Ok(())
        });
    }
    handler.kill_on_drop(true);
    handler.stdout(Stdio::piped());
    handler.stderr(Stdio::piped());

    let mut child = handler
        .spawn()
        .with_context(|| format!("Failed to execute {shell_name}"))?;
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| anyhow!("stdout pipe was unexpectedly not available"))?;
    let stderr = child
        .stderr
        .take()
        .ok_or_else(|| anyhow!("stderr pipe was unexpectedly not available"))?;

    let stdout_handle = tokio::spawn(async move {
        let mut buf = Vec::new();
        let mut stdout = stdout;
        stdout.read_to_end(&mut buf).await?;
        Ok::<Vec<u8>, std::io::Error>(buf)
    });
    let stderr_handle = tokio::spawn(async move {
        let mut buf = Vec::new();
        let mut stderr = stderr;
        stderr.read_to_end(&mut buf).await?;
        Ok::<Vec<u8>, std::io::Error>(buf)
    });

    let status = wait_for_snapshot_or_timeout(&mut child, snapshot_timeout, shell_name).await?;
    let stdout = join_snapshot_stream(stdout_handle).await?;
    let stderr = join_snapshot_stream(stderr_handle).await?;

    if !status.success() {
        let stderr = String::from_utf8_lossy(&stderr);
        bail!("Snapshot command exited with status {status}: {stderr}");
    }

    Ok(String::from_utf8_lossy(&stdout).into_owned())
}

async fn wait_for_snapshot_or_timeout(
    child: &mut Child,
    snapshot_timeout: Duration,
    shell_name: &str,
) -> Result<std::process::ExitStatus> {
    tokio::select! {
        status = child.wait() => status.with_context(|| format!("Failed to execute {shell_name}")),
        _ = sleep(snapshot_timeout) => {
            terminate_snapshot_child(child)?;
            let _ = child.wait().await;
            Err(anyhow!("Snapshot command timed out for {shell_name}"))
        }
    }
}

#[cfg(unix)]
fn terminate_snapshot_child(child: &mut Child) -> Result<()> {
    kill_child_process_group(child)?;
    child.start_kill()?;
    Ok(())
}

#[cfg(not(unix))]
fn terminate_snapshot_child(child: &mut Child) -> Result<()> {
    child.start_kill()?;
    Ok(())
}

async fn join_snapshot_stream(
    handle: tokio::task::JoinHandle<std::io::Result<Vec<u8>>>,
) -> Result<Vec<u8>> {
    match handle.await {
        Ok(result) => result.map_err(Into::into),
        Err(err) => Err(err.into()),
    }
}

fn excluded_exports_regex() -> String {
    EXCLUDED_EXPORT_VARS.join("|")
}

fn zsh_snapshot_script() -> String {
    let excluded = excluded_exports_regex();
    let script = r##"if [[ -n "$ZDOTDIR" ]]; then
  rc="$ZDOTDIR/.zshrc"
else
  rc="$HOME/.zshrc"
fi
[[ -r "$rc" ]] && . "$rc"
print '# Snapshot file'
print '# Unset all aliases to avoid conflicts with functions'
print 'unalias -a 2>/dev/null || true'
print '# Functions'
functions
print ''
setopt_count=$(setopt | wc -l | tr -d ' ')
print "# setopts $setopt_count"
setopt | sed 's/^/setopt /'
print ''
alias_count=$(alias -L | wc -l | tr -d ' ')
print "# aliases $alias_count"
alias -L
print ''
export_lines=$(export -p | awk '
/^(export|declare -x|typeset -x) / {
  line=$0
  name=line
  sub(/^(export|declare -x|typeset -x) /, "", name)
  sub(/=.*/, "", name)
  if (name ~ /^(EXCLUDED_EXPORTS)$/) {
    next
  }
  if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
    print line
  }
}')
export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
print "# exports $export_count"
if [[ -n "$export_lines" ]]; then
  print -r -- "$export_lines"
fi
"##;
    script.replace("EXCLUDED_EXPORTS", &excluded)
}

fn bash_snapshot_script() -> String {
    let excluded = excluded_exports_regex();
    let script = r##"if [ -z "$BASH_ENV" ] && [ -r "$HOME/.bashrc" ]; then
  . "$HOME/.bashrc"
fi
echo '# Snapshot file'
echo '# Unset all aliases to avoid conflicts with functions'
unalias -a 2>/dev/null || true
echo '# Functions'
declare -f
echo ''
bash_opts=$(set -o | awk '$2=="on"{print $1}')
bash_opt_count=$(printf '%s\n' "$bash_opts" | sed '/^$/d' | wc -l | tr -d ' ')
echo "# setopts $bash_opt_count"
if [ -n "$bash_opts" ]; then
  printf 'set -o %s\n' $bash_opts
fi
echo ''
alias_count=$(alias -p | wc -l | tr -d ' ')
echo "# aliases $alias_count"
alias -p
echo ''
export_lines=$(export -p | awk '
/^(export|declare -x|typeset -x) / {
  line=$0
  name=line
  sub(/^(export|declare -x|typeset -x) /, "", name)
  sub(/=.*/, "", name)
  if (name ~ /^(EXCLUDED_EXPORTS)$/) {
    next
  }
  if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
    print line
  }
}')
export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
echo "# exports $export_count"
if [ -n "$export_lines" ]; then
  printf '%s\n' "$export_lines"
fi
"##;
    script.replace("EXCLUDED_EXPORTS", &excluded)
}

fn sh_snapshot_script() -> String {
    let excluded = excluded_exports_regex();
    let script = r##"if [ -n "$ENV" ] && [ -r "$ENV" ]; then
  . "$ENV"
fi
echo '# Snapshot file'
echo '# Unset all aliases to avoid conflicts with functions'
unalias -a 2>/dev/null || true
echo '# Functions'
if command -v typeset >/dev/null 2>&1; then
  typeset -f
elif command -v declare >/dev/null 2>&1; then
  declare -f
fi
echo ''
if set -o >/dev/null 2>&1; then
  sh_opts=$(set -o | awk '$2=="on"{print $1}')
  sh_opt_count=$(printf '%s\n' "$sh_opts" | sed '/^$/d' | wc -l | tr -d ' ')
  echo "# setopts $sh_opt_count"
  if [ -n "$sh_opts" ]; then
    printf 'set -o %s\n' $sh_opts
  fi
else
  echo '# setopts 0'
fi
echo ''
if alias >/dev/null 2>&1; then
  alias_count=$(alias | wc -l | tr -d ' ')
  echo "# aliases $alias_count"
  alias
  echo ''
else
  echo '# aliases 0'
fi
if export -p >/dev/null 2>&1; then
  export_lines=$(export -p | awk '
/^(export|declare -x|typeset -x) / {
  line=$0
  name=line
  sub(/^(export|declare -x|typeset -x) /, "", name)
  sub(/=.*/, "", name)
  if (name ~ /^(EXCLUDED_EXPORTS)$/) {
    next
  }
  if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
    print line
  }
}')
  export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
  echo "# exports $export_count"
  if [ -n "$export_lines" ]; then
    printf '%s\n' "$export_lines"
  fi
else
  export_count=$(env | sort | awk -F= '$1 ~ /^[A-Za-z_][A-Za-z0-9_]*$/ { count++ } END { print count }')
  echo "# exports $export_count"
  env | sort | while IFS='=' read -r key value; do
    case "$key" in
      ""|[0-9]*|*[!A-Za-z0-9_]*|EXCLUDED_EXPORTS) continue ;;
    esac
    escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g")
    printf "export %s='%s'\n" "$key" "$escaped"
  done
fi
"##;
    script.replace("EXCLUDED_EXPORTS", &excluded)
}

fn powershell_snapshot_script() -> &'static str {
    r##"$ErrorActionPreference = 'Stop'
Write-Output '# Snapshot file'
Write-Output '# Unset all aliases to avoid conflicts with functions'
Write-Output 'Remove-Item Alias:* -ErrorAction SilentlyContinue'
Write-Output '# Functions'
Get-ChildItem Function: | ForEach-Object {
    "function {0} {{`n{1}`n}}" -f $_.Name, $_.Definition
}
Write-Output ''
$aliases = Get-Alias
Write-Output ("# aliases " + $aliases.Count)
$aliases | ForEach-Object {
    "Set-Alias -Name {0} -Value {1}" -f $_.Name, $_.Definition
}
Write-Output ''
$envVars = Get-ChildItem Env:
Write-Output ("# exports " + $envVars.Count)
$envVars | ForEach-Object {
    $escaped = $_.Value -replace "'", "''"
    "`$env:{0}='{1}'" -f $_.Name, $escaped
}
"##
}

/// Removes shell snapshots that either lack a matching session rollout file or
/// whose rollouts have not been updated within the retention window.
/// The active session id is exempt from cleanup.
pub async fn cleanup_stale_snapshots(lha_home: &Path, active_session_id: ThreadId) -> Result<()> {
    let snapshot_dir = lha_home.join(SNAPSHOT_DIR);

    let mut entries = match fs::read_dir(&snapshot_dir).await {
        Ok(entries) => entries,
        Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
        Err(err) => return Err(err.into()),
    };

    let now = SystemTime::now();
    let active_session_id = active_session_id.to_string();

    while let Some(entry) = entries.next_entry().await? {
        if !entry.file_type().await?.is_file() {
            continue;
        }

        let path = entry.path();

        let file_name = entry.file_name();
        let file_name = file_name.to_string_lossy();
        let (session_id, _) = match file_name.rsplit_once('.') {
            Some((stem, ext)) => (stem, ext),
            None => {
                remove_snapshot_file(&path).await;
                continue;
            }
        };
        if session_id == active_session_id {
            continue;
        }

        let rollout_path = find_thread_path_by_id_str(lha_home, session_id).await?;
        let Some(rollout_path) = rollout_path else {
            remove_snapshot_file(&path).await;
            continue;
        };

        let modified = match fs::metadata(&rollout_path).await.and_then(|m| m.modified()) {
            Ok(modified) => modified,
            Err(err) => {
                tracing::warn!(
                    "Failed to check rollout age for snapshot {}: {err:?}",
                    path.display()
                );
                continue;
            }
        };

        if now
            .duration_since(modified)
            .ok()
            .is_some_and(|age| age >= SNAPSHOT_RETENTION)
        {
            remove_snapshot_file(&path).await;
        }
    }

    Ok(())
}

async fn remove_snapshot_file(path: &Path) {
    if let Err(err) = fs::remove_file(path).await {
        tracing::warn!("Failed to delete shell snapshot at {:?}: {err:?}", path);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    #[cfg(unix)]
    use std::os::unix::ffi::OsStrExt;
    #[cfg(unix)]
    use std::process::Command;
    #[cfg(target_os = "linux")]
    use std::process::Command as StdCommand;

    use tempfile::tempdir;

    #[cfg(unix)]
    struct BlockingStdinPipe {
        original: i32,
        write_end: i32,
    }

    #[cfg(unix)]
    impl BlockingStdinPipe {
        fn install() -> Result<Self> {
            let mut fds = [0i32; 2];
            if unsafe { libc::pipe(fds.as_mut_ptr()) } == -1 {
                return Err(std::io::Error::last_os_error()).context("create stdin pipe");
            }

            let original = unsafe { libc::dup(libc::STDIN_FILENO) };
            if original == -1 {
                let err = std::io::Error::last_os_error();
                unsafe {
                    libc::close(fds[0]);
                    libc::close(fds[1]);
                }
                return Err(err).context("dup stdin");
            }

            if unsafe { libc::dup2(fds[0], libc::STDIN_FILENO) } == -1 {
                let err = std::io::Error::last_os_error();
                unsafe {
                    libc::close(fds[0]);
                    libc::close(fds[1]);
                    libc::close(original);
                }
                return Err(err).context("replace stdin");
            }

            unsafe {
                libc::close(fds[0]);
            }

            Ok(Self {
                original,
                write_end: fds[1],
            })
        }
    }

    #[cfg(unix)]
    impl Drop for BlockingStdinPipe {
        fn drop(&mut self) {
            unsafe {
                libc::dup2(self.original, libc::STDIN_FILENO);
                libc::close(self.original);
                libc::close(self.write_end);
            }
        }
    }

    #[cfg(not(target_os = "windows"))]
    fn assert_posix_snapshot_sections(snapshot: &str) {
        assert!(snapshot.contains("# Snapshot file"));
        assert!(snapshot.contains("aliases "));
        assert!(snapshot.contains("exports "));
        assert!(
            snapshot.contains("PATH"),
            "snapshot should capture a PATH export"
        );
        assert!(snapshot.contains("setopts "));
    }

    async fn get_snapshot(shell_type: ShellType) -> Result<String> {
        let dir = tempdir()?;
        let path = dir.path().join("snapshot.sh");
        write_shell_snapshot(shell_type, &path).await?;
        let content = fs::read_to_string(&path).await?;
        Ok(content)
    }

    #[test]
    fn strip_snapshot_preamble_removes_leading_output() {
        let snapshot = "noise\n# Snapshot file\nexport PATH=/bin\n";
        let cleaned = strip_snapshot_preamble(snapshot).expect("snapshot marker exists");
        assert_eq!(cleaned, "# Snapshot file\nexport PATH=/bin\n");
    }

    #[test]
    fn strip_snapshot_preamble_requires_marker() {
        let result = strip_snapshot_preamble("missing header");
        assert!(result.is_err());
    }

    #[cfg(unix)]
    #[test]
    fn bash_snapshot_filters_invalid_exports() -> Result<()> {
        let output = Command::new("/bin/bash")
            .arg("-c")
            .arg(bash_snapshot_script())
            .env("BASH_ENV", "/dev/null")
            .env("VALID_NAME", "ok")
            .env("PWD", "/tmp/stale")
            .env("NEXTEST_BIN_EXE_lha-write-config-schema", "/path/to/bin")
            .env("BAD-NAME", "broken")
            .output()?;

        assert!(output.status.success());

        let stdout = String::from_utf8_lossy(&output.stdout);
        assert!(stdout.contains("VALID_NAME"));
        assert!(!stdout.contains("PWD=/tmp/stale"));
        assert!(!stdout.contains("NEXTEST_BIN_EXE_lha-write-config-schema"));
        assert!(!stdout.contains("BAD-NAME"));

        Ok(())
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> {
        let dir = tempdir()?;
        let shell = Shell {
            shell_type: ShellType::Bash,
            shell_path: PathBuf::from("/bin/bash"),
            shell_snapshot: crate::product::agent::shell::empty_shell_snapshot_receiver(),
        };

        let snapshot = ShellSnapshot::try_new(dir.path(), ThreadId::new(), &shell)
            .await
            .expect("snapshot should be created");
        let path = snapshot.path.clone();
        assert!(path.exists());

        drop(snapshot);

        assert!(!path.exists());

        Ok(())
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn snapshot_shell_does_not_inherit_stdin() -> Result<()> {
        let _stdin_guard = BlockingStdinPipe::install()?;

        let dir = tempdir()?;
        let home = dir.path();
        fs::write(home.join(".bashrc"), "read -r ignored\n").await?;

        let shell = Shell {
            shell_type: ShellType::Bash,
            shell_path: PathBuf::from("/bin/bash"),
            shell_snapshot: crate::product::agent::shell::empty_shell_snapshot_receiver(),
        };

        let home_display = home.display();
        let script = format!(
            "HOME=\"{home_display}\"; export HOME; {}",
            bash_snapshot_script()
        );
        let output = run_script_with_timeout(&shell, &script, Duration::from_millis(500), true)
            .await
            .context("run snapshot command")?;

        assert!(
            output.contains("# Snapshot file"),
            "expected snapshot marker in output; output={output:?}"
        );

        Ok(())
    }

    #[cfg(target_os = "linux")]
    #[tokio::test]
    async fn timed_out_snapshot_shell_is_terminated() -> Result<()> {
        use std::process::Stdio;
        use tokio::time::Duration as TokioDuration;
        use tokio::time::Instant;
        use tokio::time::sleep;

        let dir = tempdir()?;
        let pid_path = dir.path().join("pid");
        let script = format!("echo $$ > \"{}\"; sleep 30", pid_path.display());

        let shell = Shell {
            shell_type: ShellType::Sh,
            shell_path: PathBuf::from("/bin/sh"),
            shell_snapshot: crate::product::agent::shell::empty_shell_snapshot_receiver(),
        };

        let err = run_script_with_timeout(&shell, &script, Duration::from_secs(1), true)
            .await
            .expect_err("snapshot shell should time out");
        assert!(
            err.to_string().contains("timed out"),
            "expected timeout error, got {err:?}"
        );

        let pid = fs::read_to_string(&pid_path)
            .await
            .expect("snapshot shell writes its pid before timing out")
            .trim()
            .parse::<i32>()?;

        let deadline = Instant::now() + TokioDuration::from_secs(1);
        loop {
            let kill_status = StdCommand::new("kill")
                .arg("-0")
                .arg(pid.to_string())
                .stderr(Stdio::null())
                .stdout(Stdio::null())
                .status()?;
            if !kill_status.success() {
                break;
            }
            if Instant::now() >= deadline {
                panic!("timed out snapshot shell is still alive after grace period");
            }
            sleep(TokioDuration::from_millis(50)).await;
        }

        Ok(())
    }

    #[cfg(target_os = "macos")]
    #[tokio::test]
    async fn macos_zsh_snapshot_includes_sections() -> Result<()> {
        let snapshot = get_snapshot(ShellType::Zsh).await?;
        assert_posix_snapshot_sections(&snapshot);
        Ok(())
    }

    #[cfg(target_os = "linux")]
    #[tokio::test]
    async fn linux_bash_snapshot_includes_sections() -> Result<()> {
        let snapshot = get_snapshot(ShellType::Bash).await?;
        assert_posix_snapshot_sections(&snapshot);
        Ok(())
    }

    #[cfg(target_os = "linux")]
    #[tokio::test]
    async fn linux_sh_snapshot_includes_sections() -> Result<()> {
        let snapshot = get_snapshot(ShellType::Sh).await?;
        assert_posix_snapshot_sections(&snapshot);
        Ok(())
    }

    #[cfg(target_os = "windows")]
    #[ignore]
    #[tokio::test]
    async fn windows_powershell_snapshot_includes_sections() -> Result<()> {
        let snapshot = get_snapshot(ShellType::PowerShell).await?;
        assert!(snapshot.contains("# Snapshot file"));
        assert!(snapshot.contains("aliases "));
        assert!(snapshot.contains("exports "));
        Ok(())
    }

    async fn write_rollout_stub(lha_home: &Path, session_id: ThreadId) -> Result<PathBuf> {
        let dir = lha_home.join("sessions").join("2025").join("01").join("01");
        fs::create_dir_all(&dir).await?;
        let path = dir.join(format!("rollout-2025-01-01T00-00-00-{session_id}.jsonl"));
        fs::write(&path, "").await?;
        Ok(path)
    }

    #[tokio::test]
    async fn cleanup_stale_snapshots_removes_orphans_and_keeps_live() -> Result<()> {
        let dir = tempdir()?;
        let lha_home = dir.path();
        let snapshot_dir = lha_home.join(SNAPSHOT_DIR);
        fs::create_dir_all(&snapshot_dir).await?;

        let live_session = ThreadId::new();
        let orphan_session = ThreadId::new();
        let live_snapshot = snapshot_dir.join(format!("{live_session}.sh"));
        let orphan_snapshot = snapshot_dir.join(format!("{orphan_session}.sh"));
        let invalid_snapshot = snapshot_dir.join("not-a-snapshot.txt");

        write_rollout_stub(lha_home, live_session).await?;
        fs::write(&live_snapshot, "live").await?;
        fs::write(&orphan_snapshot, "orphan").await?;
        fs::write(&invalid_snapshot, "invalid").await?;

        cleanup_stale_snapshots(lha_home, ThreadId::new()).await?;

        assert_eq!(live_snapshot.exists(), true);
        assert_eq!(orphan_snapshot.exists(), false);
        assert_eq!(invalid_snapshot.exists(), false);
        Ok(())
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn cleanup_stale_snapshots_removes_stale_rollouts() -> Result<()> {
        let dir = tempdir()?;
        let lha_home = dir.path();
        let snapshot_dir = lha_home.join(SNAPSHOT_DIR);
        fs::create_dir_all(&snapshot_dir).await?;

        let stale_session = ThreadId::new();
        let stale_snapshot = snapshot_dir.join(format!("{stale_session}.sh"));
        let rollout_path = write_rollout_stub(lha_home, stale_session).await?;
        fs::write(&stale_snapshot, "stale").await?;

        set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?;

        cleanup_stale_snapshots(lha_home, ThreadId::new()).await?;

        assert_eq!(stale_snapshot.exists(), false);
        Ok(())
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn cleanup_stale_snapshots_skips_active_session() -> Result<()> {
        let dir = tempdir()?;
        let lha_home = dir.path();
        let snapshot_dir = lha_home.join(SNAPSHOT_DIR);
        fs::create_dir_all(&snapshot_dir).await?;

        let active_session = ThreadId::new();
        let active_snapshot = snapshot_dir.join(format!("{active_session}.sh"));
        let rollout_path = write_rollout_stub(lha_home, active_session).await?;
        fs::write(&active_snapshot, "active").await?;

        set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?;

        cleanup_stale_snapshots(lha_home, active_session).await?;

        assert_eq!(active_snapshot.exists(), true);
        Ok(())
    }

    #[cfg(unix)]
    fn set_file_mtime(path: &Path, age: Duration) -> Result<()> {
        let now = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)?
            .as_secs()
            .saturating_sub(age.as_secs());
        let tv_sec = now
            .try_into()
            .map_err(|_| anyhow!("Snapshot mtime is out of range for libc::timespec"))?;
        let ts = libc::timespec { tv_sec, tv_nsec: 0 };
        let times = [ts, ts];
        let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?;
        let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
        if result != 0 {
            return Err(std::io::Error::last_os_error().into());
        }
        Ok(())
    }
}
