use serial_test::serial;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::Duration;

fn bmux_binary() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_bmux"))
}

fn workspace_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("workspace root from package path")
        .to_path_buf()
}

struct TempDirGuard {
    path: PathBuf,
}

impl TempDirGuard {
    fn new(label: &str) -> Self {
        let path = std::env::temp_dir().join(format!(
            "bmux-sandbox-integration-{label}-{}",
            uuid::Uuid::new_v4()
        ));
        std::fs::create_dir_all(&path).expect("create temp dir");
        Self { path }
    }

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

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

struct CommandSandbox {
    root: TempDirGuard,
}

impl CommandSandbox {
    fn new(label: &str) -> Self {
        let root = TempDirGuard::new(label);
        for dir in ["config", "runtime", "data", "state", "logs", "tmp-root"] {
            std::fs::create_dir_all(root.path().join(dir)).expect("create sandbox dir");
        }
        Self { root }
    }

    fn command(&self) -> Command {
        sandbox_command_for_root(self.root.path())
    }

    fn write_config(&self, toml: &str) {
        std::fs::write(self.root.path().join("config").join("bmux.toml"), toml)
            .expect("write sandbox config file");
    }

    fn sandbox_index_path(&self) -> PathBuf {
        self.root
            .path()
            .join("state")
            .join("sandbox")
            .join("index.json")
    }
}

fn sandbox_command_for_root(root: &Path) -> Command {
    let mut command = Command::new(bmux_binary());
    command
        .current_dir(root)
        .env("BMUX_CONFIG_DIR", root.join("config"))
        .env("BMUX_RUNTIME_DIR", root.join("runtime"))
        .env("BMUX_DATA_DIR", root.join("data"))
        .env("BMUX_STATE_DIR", root.join("state"))
        .env("BMUX_LOG_DIR", root.join("logs"))
        .env("BMUX_TARGET", "")
        .env("TMPDIR", root.join("tmp-root"));
    command
}

fn parse_json_stdout(output: &std::process::Output) -> serde_json::Value {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let start = stdout
        .find('{')
        .unwrap_or_else(|| panic!("stdout did not include json object: {stdout}"));
    serde_json::from_str(&stdout[start..])
        .unwrap_or_else(|error| panic!("stdout was not json: {error}; stdout={stdout}"))
}

fn assert_schema_version(payload: &serde_json::Value) {
    assert_eq!(
        payload["schema_version"].as_u64(),
        Some(1),
        "json payload should include schema_version=1"
    );
}

fn create_manifest_sandbox(root: &Path, dir_name: &str, source: &str, status: &str) {
    let dir = root.join(dir_name);
    let logs = dir.join("logs");
    let runtime = dir.join("runtime");
    let state = dir.join("state");
    std::fs::create_dir_all(&logs).expect("create logs dir");
    std::fs::create_dir_all(&runtime).expect("create runtime dir");
    std::fs::create_dir_all(&state).expect("create state dir");

    let manifest = serde_json::json!({
        "id": dir_name,
        "source": source,
        "created_at_unix_ms": 1,
        "updated_at_unix_ms": 1,
        "pid": 999_999,
        "bmux_bin": "bmux",
        "command": ["--version"],
        "env_mode": "clean",
        "status": status,
        "exit_code": if status == "failed" { serde_json::json!(1) } else { serde_json::json!(0) },
        "kept": true,
        "paths": {
            "root": dir.to_string_lossy(),
            "logs": logs.to_string_lossy(),
            "runtime": runtime.to_string_lossy(),
            "state": state.to_string_lossy(),
        }
    });
    std::fs::write(
        dir.join("sandbox.json"),
        serde_json::to_vec_pretty(&manifest).expect("serialize manifest"),
    )
    .expect("write sandbox manifest");
}

fn write_index_entries(path: &Path, entries: serde_json::Value) {
    let parent = path.parent().expect("index path parent should exist");
    std::fs::create_dir_all(parent).expect("create sandbox index dir");
    let payload = serde_json::json!({
        "schema_version": 1,
        "entries": entries,
    });
    std::fs::write(
        path,
        serde_json::to_vec_pretty(&payload).expect("serialize index payload"),
    )
    .expect("write sandbox index payload");
}

fn write_stale_lock(root: &Path, pid: u32) {
    let lock = serde_json::json!({
        "pid": pid,
        "updated_at_unix_ms": 1,
    });
    std::fs::write(
        root.join("sandbox.lock"),
        serde_json::to_vec(&lock).expect("serialize lock payload"),
    )
    .expect("write stale sandbox lock");
}

#[test]
#[serial]
fn sandbox_dev_prefers_workspace_debug_binary() {
    let sandbox = CommandSandbox::new("dev-prefers-debug-binary");
    let mut command = sandbox.command();
    let output = command
        .current_dir(workspace_root())
        .args(["sandbox", "dev", "--json", "--", "--version"])
        .output()
        .expect("run bmux sandbox dev");
    assert!(
        output.status.success(),
        "sandbox dev should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    let expected = workspace_root().join("target").join("debug").join("bmux");
    if expected.exists() {
        let bmux_bin = json["bmux_bin"]
            .as_str()
            .expect("sandbox dev json should include bmux_bin");
        assert_eq!(
            Path::new(bmux_bin),
            expected,
            "sandbox dev should prefer workspace debug binary"
        );
    }
}

#[test]
#[serial]
fn sandbox_inspect_latest_and_latest_failed_resolve_expected_runs() {
    let sandbox = CommandSandbox::new("inspect-latest");

    let success_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--keep",
            "--name",
            "latest-success",
            "--",
            "--version",
        ])
        .output()
        .expect("run successful sandbox command");
    assert!(
        success_output.status.success(),
        "successful sandbox run should pass"
    );

    let failed_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "latest-failed",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox command");
    assert!(
        !failed_output.status.success(),
        "failed sandbox run should return nonzero"
    );

    let latest = sandbox
        .command()
        .args(["sandbox", "inspect", "--latest", "--json"])
        .output()
        .expect("inspect latest sandbox");
    assert!(latest.status.success(), "inspect latest should succeed");
    let latest_json = parse_json_stdout(&latest);
    assert_schema_version(&latest_json);
    let latest_status = latest_json["manifest"]["status"]
        .as_str()
        .expect("latest inspect should include manifest status");
    assert_eq!(latest_status, "failed");

    let latest_failed = sandbox
        .command()
        .args(["sandbox", "inspect", "--latest-failed", "--json"])
        .output()
        .expect("inspect latest failed sandbox");
    assert!(
        latest_failed.status.success(),
        "inspect latest failed should succeed"
    );
    let latest_failed_json = parse_json_stdout(&latest_failed);
    assert_schema_version(&latest_failed_json);
    let command = latest_failed_json["manifest"]["command"]
        .as_array()
        .expect("latest failed inspect should include command array");
    assert_eq!(
        command.first().and_then(serde_json::Value::as_str),
        Some("no-such-command")
    );
}

#[test]
#[serial]
fn sandbox_bundle_writes_manifest_logs_and_repro() {
    let sandbox = CommandSandbox::new("bundle-contents");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "bundle-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for bundle source");
    assert!(
        !run_output.status.success(),
        "source sandbox should fail and be kept"
    );
    let run_json = parse_json_stdout(&run_output);
    assert_schema_version(&run_json);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts");
    assert!(
        bundle_output.status.success(),
        "bundle should succeed; stderr={}; stdout={}",
        String::from_utf8_lossy(&bundle_output.stderr),
        String::from_utf8_lossy(&bundle_output.stdout)
    );

    let bundle_json = parse_json_stdout(&bundle_output);
    assert_schema_version(&bundle_json);
    let bundle_dir = PathBuf::from(
        bundle_json["bundle_dir"]
            .as_str()
            .expect("bundle json should include bundle_dir"),
    );
    assert!(
        bundle_dir.join("sandbox.json").exists(),
        "bundle should include manifest"
    );
    assert!(
        bundle_dir.join("logs").exists(),
        "bundle should include logs directory"
    );
    assert!(
        bundle_dir.join("repro.txt").exists(),
        "bundle should include repro command"
    );
    assert!(
        bundle_dir.join("bundle_manifest.json").exists(),
        "bundle should include bundle manifest metadata"
    );

    let repro =
        std::fs::read_to_string(bundle_dir.join("repro.txt")).expect("read bundled repro command");
    assert!(
        repro.contains("bmux sandbox run"),
        "repro command should include sandbox run"
    );
}

#[test]
#[serial]
fn sandbox_bundle_without_output_uses_test_temp_root() {
    let sandbox = CommandSandbox::new("bundle-default-output-root");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "bundle-default-output-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for default output source");
    assert!(
        !run_output.status.success(),
        "source sandbox should fail and be kept"
    );
    let run_json = parse_json_stdout(&run_output);
    assert_schema_version(&run_json);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args(["sandbox", "bundle", sandbox_id.as_str(), "--json"])
        .output()
        .expect("bundle sandbox artifacts with default output directory");
    assert!(
        bundle_output.status.success(),
        "bundle should succeed; stderr={}; stdout={}",
        String::from_utf8_lossy(&bundle_output.stderr),
        String::from_utf8_lossy(&bundle_output.stdout)
    );

    let bundle_json = parse_json_stdout(&bundle_output);
    assert_schema_version(&bundle_json);
    let bundle_dir = PathBuf::from(
        bundle_json["bundle_dir"]
            .as_str()
            .expect("bundle json should include bundle_dir"),
    );
    assert!(bundle_dir.is_dir(), "bundle output should be a directory");

    let expected_root = sandbox
        .root
        .path()
        .join("sandbox-bundles")
        .canonicalize()
        .expect("expected bundle root should exist");
    let canonical_bundle_dir = bundle_dir
        .canonicalize()
        .expect("bundle directory should canonicalize");
    assert!(
        canonical_bundle_dir.starts_with(&expected_root),
        "default bundle output should live under test temp root; expected prefix={}, actual={}",
        expected_root.display(),
        canonical_bundle_dir.display()
    );
}

#[test]
#[serial]
fn sandbox_bundle_includes_optional_artifacts_when_requested() {
    let sandbox = CommandSandbox::new("bundle-optional-artifacts");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "bundle-optional-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for optional bundle source");
    assert!(
        !run_output.status.success(),
        "source sandbox should fail and be kept"
    );
    let run_json = parse_json_stdout(&run_output);
    assert_schema_version(&run_json);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--include-env",
            "--include-index-state",
            "--include-doctor",
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts with optional files");
    assert!(
        bundle_output.status.success(),
        "bundle should succeed; stderr={}; stdout={}",
        String::from_utf8_lossy(&bundle_output.stderr),
        String::from_utf8_lossy(&bundle_output.stdout)
    );

    let bundle_json = parse_json_stdout(&bundle_output);
    assert_schema_version(&bundle_json);
    let bundle_dir = PathBuf::from(
        bundle_json["bundle_dir"]
            .as_str()
            .expect("bundle json should include bundle_dir"),
    );

    assert!(
        bundle_dir.join("env.json").exists(),
        "bundle should include env snapshot"
    );
    assert!(
        bundle_dir.join("sandbox-index-entry.json").exists(),
        "bundle should include index entry snapshot"
    );
    assert!(
        bundle_dir.join("doctor.json").exists(),
        "bundle should include doctor snapshot"
    );

    let metadata: serde_json::Value = serde_json::from_slice(
        &std::fs::read(bundle_dir.join("bundle_manifest.json")).expect("read bundle metadata"),
    )
    .expect("parse bundle metadata");
    assert_eq!(metadata["includes"]["env"].as_bool(), Some(true));
    assert_eq!(metadata["includes"]["index_state"].as_bool(), Some(true));
    assert_eq!(metadata["includes"]["doctor"].as_bool(), Some(true));

    let artifact_metadata = metadata["artifact_metadata"]
        .as_array()
        .expect("bundle metadata should include artifact metadata");
    let env_entry = artifact_metadata
        .iter()
        .find(|entry| entry["path"].as_str() == Some("env.json"))
        .expect("env metadata entry should exist");
    assert_eq!(env_entry["kind"].as_str(), Some("file"));
    assert_eq!(env_entry["exists"].as_bool(), Some(true));
    assert!(
        env_entry["bytes"].as_u64().is_some_and(|bytes| bytes > 0),
        "env metadata should include file bytes"
    );
    assert!(
        env_entry["sha256"]
            .as_str()
            .is_some_and(|hash| hash.len() == 64),
        "env metadata should include sha256 hash"
    );

    let logs_entry = artifact_metadata
        .iter()
        .find(|entry| entry["path"].as_str() == Some("logs/"))
        .expect("logs metadata entry should exist");
    assert_eq!(logs_entry["kind"].as_str(), Some("directory"));
    assert_eq!(logs_entry["exists"].as_bool(), Some(true));
    assert!(
        logs_entry["file_count"]
            .as_u64()
            .is_some_and(|count| count >= 1),
        "logs metadata should include at least one file"
    );
    assert!(
        logs_entry["sha256"]
            .as_str()
            .is_some_and(|hash| hash.len() == 64),
        "logs metadata should include sha256 hash"
    );
}

#[test]
#[serial]
fn sandbox_bundle_verify_flag_runs_post_bundle_verification() {
    let sandbox = CommandSandbox::new("bundle-verify-flag");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "bundle-verify-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for bundle verify source");
    assert!(
        !run_output.status.success(),
        "source sandbox should fail and be kept"
    );
    let run_json = parse_json_stdout(&run_output);
    assert_schema_version(&run_json);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--include-env",
            "--verify",
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts with --verify");
    assert!(
        bundle_output.status.success(),
        "bundle --verify should succeed; stderr={}; stdout={}",
        String::from_utf8_lossy(&bundle_output.stderr),
        String::from_utf8_lossy(&bundle_output.stdout)
    );

    let bundle_json = parse_json_stdout(&bundle_output);
    assert_schema_version(&bundle_json);
    assert_eq!(bundle_json["verify"]["ok"].as_bool(), Some(true));
    assert_eq!(bundle_json["verify"]["issue_count"].as_u64(), Some(0));
    assert_eq!(
        bundle_json["verify"]["mode"].as_str(),
        Some("strict_metadata")
    );
}

#[test]
#[serial]
fn sandbox_verify_bundle_reports_ok_for_fresh_bundle() {
    let sandbox = CommandSandbox::new("verify-bundle-ok");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "verify-bundle-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for verify-bundle source");
    assert!(
        !run_output.status.success(),
        "source sandbox should fail and be kept"
    );
    let run_json = parse_json_stdout(&run_output);
    assert_schema_version(&run_json);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--include-env",
            "--include-index-state",
            "--include-doctor",
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts for verify-bundle");
    assert!(bundle_output.status.success(), "bundle should succeed");
    let bundle_json = parse_json_stdout(&bundle_output);
    assert_schema_version(&bundle_json);
    let bundle_dir = bundle_json["bundle_dir"]
        .as_str()
        .expect("bundle json should include bundle_dir")
        .to_string();

    let verify_output = sandbox
        .command()
        .args(["sandbox", "verify-bundle", bundle_dir.as_str(), "--json"])
        .output()
        .expect("verify fresh bundle");
    assert!(
        verify_output.status.success(),
        "verify-bundle should succeed for untouched bundle: {}",
        String::from_utf8_lossy(&verify_output.stderr)
    );

    let verify_json = parse_json_stdout(&verify_output);
    assert_schema_version(&verify_json);
    assert_eq!(verify_json["ok"].as_bool(), Some(true));
    assert_eq!(verify_json["strict"].as_bool(), Some(false));
    assert_eq!(verify_json["mode"].as_str(), Some("strict_metadata"));
    assert_eq!(
        verify_json["unexpected_artifacts"].as_array().map(Vec::len),
        Some(0)
    );
    assert_eq!(verify_json["version_check"]["ok"].as_bool(), Some(true));
    assert_eq!(verify_json["issue_count"].as_u64(), Some(0));
}

#[test]
#[serial]
fn sandbox_verify_bundle_reports_unexpected_artifacts_without_failing_by_default() {
    let sandbox = CommandSandbox::new("verify-bundle-unexpected-nonstrict");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "verify-bundle-unexpected-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for unexpected artifact source");
    assert!(!run_output.status.success(), "source sandbox should fail");
    let run_json = parse_json_stdout(&run_output);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--include-env",
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts for unexpected artifact non-strict check");
    assert!(bundle_output.status.success(), "bundle should succeed");
    let bundle_json = parse_json_stdout(&bundle_output);
    let bundle_dir = PathBuf::from(
        bundle_json["bundle_dir"]
            .as_str()
            .expect("bundle json should include bundle_dir"),
    );

    std::fs::write(bundle_dir.join("extra.txt"), "extra").expect("write extra artifact file");

    let verify_output = sandbox
        .command()
        .args([
            "sandbox",
            "verify-bundle",
            bundle_dir.to_string_lossy().as_ref(),
            "--json",
        ])
        .output()
        .expect("verify bundle with unexpected artifact in non-strict mode");
    assert!(
        verify_output.status.success(),
        "non-strict verify should pass with unexpected artifacts"
    );

    let verify_json = parse_json_stdout(&verify_output);
    assert_eq!(verify_json["ok"].as_bool(), Some(true));
    assert_eq!(verify_json["strict"].as_bool(), Some(false));
    assert!(
        verify_json["unexpected_artifacts"]
            .as_array()
            .is_some_and(|items| items.iter().any(|item| item.as_str() == Some("extra.txt"))),
        "verify output should report unexpected artifact"
    );
    assert_eq!(verify_json["issue_count"].as_u64(), Some(0));
}

#[test]
#[serial]
fn sandbox_verify_bundle_strict_fails_on_unexpected_artifacts() {
    let sandbox = CommandSandbox::new("verify-bundle-unexpected-strict");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "verify-bundle-unexpected-strict-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for strict unexpected artifact source");
    assert!(!run_output.status.success(), "source sandbox should fail");
    let run_json = parse_json_stdout(&run_output);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--include-env",
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts for strict unexpected artifact check");
    assert!(bundle_output.status.success(), "bundle should succeed");
    let bundle_json = parse_json_stdout(&bundle_output);
    let bundle_dir = PathBuf::from(
        bundle_json["bundle_dir"]
            .as_str()
            .expect("bundle json should include bundle_dir"),
    );

    std::fs::write(bundle_dir.join("extra.txt"), "extra").expect("write extra artifact file");

    let verify_output = sandbox
        .command()
        .args([
            "sandbox",
            "verify-bundle",
            bundle_dir.to_string_lossy().as_ref(),
            "--strict",
            "--json",
        ])
        .output()
        .expect("verify bundle with unexpected artifact in strict mode");
    assert!(
        !verify_output.status.success(),
        "strict verify should fail with unexpected artifacts"
    );

    let verify_json = parse_json_stdout(&verify_output);
    assert_eq!(verify_json["ok"].as_bool(), Some(false));
    assert_eq!(verify_json["strict"].as_bool(), Some(true));
    let issues = verify_json["issues"]
        .as_array()
        .expect("issues should be an array");
    assert!(
        issues.iter().any(|issue| {
            issue["path"].as_str() == Some("extra.txt")
                && issue["field"].as_str() == Some("unexpected_artifact")
        }),
        "strict verify should include unexpected artifact issue"
    );
}

#[test]
#[serial]
fn sandbox_verify_bundle_fails_for_unsupported_bundle_version() {
    let sandbox = CommandSandbox::new("verify-bundle-version-check");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "verify-bundle-version-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for version-check source");
    assert!(!run_output.status.success(), "source sandbox should fail");
    let run_json = parse_json_stdout(&run_output);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--include-env",
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts for version-check validation");
    assert!(bundle_output.status.success(), "bundle should succeed");
    let bundle_json = parse_json_stdout(&bundle_output);
    let bundle_dir = PathBuf::from(
        bundle_json["bundle_dir"]
            .as_str()
            .expect("bundle json should include bundle_dir"),
    );

    let bundle_manifest_path = bundle_dir.join("bundle_manifest.json");
    let mut bundle_manifest: serde_json::Value = serde_json::from_slice(
        &std::fs::read(&bundle_manifest_path).expect("read bundle manifest json"),
    )
    .expect("parse bundle manifest json");
    bundle_manifest["bundle_version"] = serde_json::json!(999);
    std::fs::write(
        &bundle_manifest_path,
        serde_json::to_vec_pretty(&bundle_manifest).expect("serialize mutated bundle manifest"),
    )
    .expect("write mutated bundle manifest");

    let verify_output = sandbox
        .command()
        .args([
            "sandbox",
            "verify-bundle",
            bundle_dir.to_string_lossy().as_ref(),
            "--json",
        ])
        .output()
        .expect("verify bundle with unsupported version");
    assert!(
        !verify_output.status.success(),
        "verify-bundle should fail for unsupported bundle version"
    );

    let verify_json = parse_json_stdout(&verify_output);
    assert_eq!(verify_json["ok"].as_bool(), Some(false));
    assert_eq!(verify_json["version_check"]["ok"].as_bool(), Some(false));
    assert!(
        verify_json["version_check"]["reason"]
            .as_str()
            .is_some_and(|reason| reason.contains("bundle_version")),
        "version check reason should mention bundle_version"
    );
}

#[test]
#[serial]
fn sandbox_verify_bundle_detects_sha256_mismatch() {
    let sandbox = CommandSandbox::new("verify-bundle-sha256-mismatch");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "verify-bundle-sha-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for sha mismatch source");
    assert!(!run_output.status.success(), "source sandbox should fail");
    let run_json = parse_json_stdout(&run_output);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--include-env",
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts for sha mismatch");
    assert!(bundle_output.status.success(), "bundle should succeed");
    let bundle_json = parse_json_stdout(&bundle_output);
    let bundle_dir = PathBuf::from(
        bundle_json["bundle_dir"]
            .as_str()
            .expect("bundle json should include bundle_dir"),
    );

    let bundle_manifest_path = bundle_dir.join("bundle_manifest.json");
    let mut bundle_manifest: serde_json::Value = serde_json::from_slice(
        &std::fs::read(&bundle_manifest_path).expect("read bundle manifest json"),
    )
    .expect("parse bundle manifest json");

    let artifact_metadata = bundle_manifest["artifact_metadata"]
        .as_array_mut()
        .expect("artifact metadata should be an array");
    let env_entry = artifact_metadata
        .iter_mut()
        .find(|entry| entry["path"].as_str() == Some("env.json"))
        .expect("env artifact metadata entry should exist");
    env_entry["sha256"] =
        serde_json::json!("0000000000000000000000000000000000000000000000000000000000000000");

    std::fs::write(
        &bundle_manifest_path,
        serde_json::to_vec_pretty(&bundle_manifest).expect("serialize mutated bundle manifest"),
    )
    .expect("write mutated bundle manifest");

    let verify_output = sandbox
        .command()
        .args([
            "sandbox",
            "verify-bundle",
            bundle_dir.to_string_lossy().as_ref(),
            "--json",
        ])
        .output()
        .expect("verify bundle with sha mismatch");
    assert!(
        !verify_output.status.success(),
        "verify-bundle should fail for sha mismatch"
    );

    let verify_json = parse_json_stdout(&verify_output);
    let issues = verify_json["issues"]
        .as_array()
        .expect("issues should be an array");
    assert!(
        issues.iter().any(|issue| {
            issue["path"].as_str() == Some("env.json") && issue["field"].as_str() == Some("sha256")
        }),
        "verify output should include sha256 mismatch issue"
    );
}

#[test]
#[serial]
fn sandbox_verify_bundle_accepts_manifest_without_sha256_metadata() {
    let sandbox = CommandSandbox::new("verify-bundle-missing-sha256");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "verify-bundle-legacy-sha-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for legacy sha source");
    assert!(!run_output.status.success(), "source sandbox should fail");
    let run_json = parse_json_stdout(&run_output);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--include-env",
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts for legacy sha validation");
    assert!(bundle_output.status.success(), "bundle should succeed");
    let bundle_json = parse_json_stdout(&bundle_output);
    let bundle_dir = PathBuf::from(
        bundle_json["bundle_dir"]
            .as_str()
            .expect("bundle json should include bundle_dir"),
    );

    let bundle_manifest_path = bundle_dir.join("bundle_manifest.json");
    let mut bundle_manifest: serde_json::Value = serde_json::from_slice(
        &std::fs::read(&bundle_manifest_path).expect("read bundle manifest json"),
    )
    .expect("parse bundle manifest json");

    let artifact_metadata = bundle_manifest["artifact_metadata"]
        .as_array_mut()
        .expect("artifact metadata should be an array");
    for entry in artifact_metadata.iter_mut() {
        if let Some(object) = entry.as_object_mut() {
            object.remove("sha256");
        }
    }

    std::fs::write(
        &bundle_manifest_path,
        serde_json::to_vec_pretty(&bundle_manifest).expect("serialize mutated bundle manifest"),
    )
    .expect("write mutated bundle manifest");

    let verify_output = sandbox
        .command()
        .args([
            "sandbox",
            "verify-bundle",
            bundle_dir.to_string_lossy().as_ref(),
            "--json",
        ])
        .output()
        .expect("verify bundle without sha metadata");
    assert!(
        verify_output.status.success(),
        "verify-bundle should accept manifests without sha256 metadata"
    );

    let verify_json = parse_json_stdout(&verify_output);
    assert_eq!(verify_json["ok"].as_bool(), Some(true));
}

#[test]
#[serial]
fn sandbox_verify_bundle_detects_artifact_drift() {
    let sandbox = CommandSandbox::new("verify-bundle-drift");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--name",
            "verify-bundle-drift-source",
            "--",
            "no-such-command",
        ])
        .output()
        .expect("run failed sandbox for verify-bundle drift source");
    assert!(
        !run_output.status.success(),
        "source sandbox should fail and be kept"
    );
    let run_json = parse_json_stdout(&run_output);
    assert_schema_version(&run_json);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let bundle_output = sandbox
        .command()
        .args([
            "sandbox",
            "bundle",
            sandbox_id.as_str(),
            "--include-env",
            "--output",
            sandbox
                .root
                .path()
                .join("bundles")
                .to_string_lossy()
                .as_ref(),
            "--json",
        ])
        .output()
        .expect("bundle sandbox artifacts for drift verification");
    assert!(bundle_output.status.success(), "bundle should succeed");
    let bundle_json = parse_json_stdout(&bundle_output);
    assert_schema_version(&bundle_json);
    let bundle_dir = PathBuf::from(
        bundle_json["bundle_dir"]
            .as_str()
            .expect("bundle json should include bundle_dir"),
    );

    std::fs::remove_file(bundle_dir.join("env.json")).expect("remove env artifact for drift");

    let verify_output = sandbox
        .command()
        .args([
            "sandbox",
            "verify-bundle",
            bundle_dir.to_string_lossy().as_ref(),
            "--json",
        ])
        .output()
        .expect("verify drifted bundle");
    assert!(
        !verify_output.status.success(),
        "verify-bundle should fail when artifact drift is present"
    );

    let verify_json = parse_json_stdout(&verify_output);
    assert_schema_version(&verify_json);
    assert_eq!(verify_json["ok"].as_bool(), Some(false));
    assert_eq!(verify_json["mode"].as_str(), Some("strict_metadata"));
    assert!(
        verify_json["issue_count"]
            .as_u64()
            .is_some_and(|count| count >= 1),
        "verify-bundle should report at least one issue"
    );
    let issues = verify_json["issues"]
        .as_array()
        .expect("issues should be an array");
    assert!(
        issues.iter().any(|issue| {
            issue["path"].as_str() == Some("env.json") && issue["field"].as_str() == Some("exists")
        }),
        "issues should include missing env.json existence mismatch"
    );
}

#[test]
#[serial]
fn sandbox_inspect_explicit_id_resolves_target_manifest() {
    let sandbox = CommandSandbox::new("inspect-explicit-id");

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--json",
            "--keep",
            "--name",
            "explicit-id",
            "--",
            "--version",
        ])
        .output()
        .expect("run sandbox for explicit id inspect");
    assert!(
        run_output.status.success(),
        "sandbox run should succeed: {}",
        String::from_utf8_lossy(&run_output.stderr)
    );

    let run_json = parse_json_stdout(&run_output);
    assert_schema_version(&run_json);
    let sandbox_id = run_json["sandbox_id"]
        .as_str()
        .expect("sandbox run json should include sandbox_id")
        .to_string();

    let inspect_output = sandbox
        .command()
        .args(["sandbox", "inspect", sandbox_id.as_str(), "--json"])
        .output()
        .expect("inspect explicit sandbox id");
    assert!(
        inspect_output.status.success(),
        "inspect explicit id should succeed: {}",
        String::from_utf8_lossy(&inspect_output.stderr)
    );

    let inspect_json = parse_json_stdout(&inspect_output);
    assert_schema_version(&inspect_json);
    let manifest_id = inspect_json["manifest"]["id"]
        .as_str()
        .expect("inspect output should include manifest id");
    assert_eq!(manifest_id, sandbox_id);
}

#[test]
#[serial]
fn sandbox_list_source_filter_returns_matching_source_only() {
    let sandbox = CommandSandbox::new("list-source-filter");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bpb-source-playbook", "playbook", "succeeded");
    create_manifest_sandbox(
        &tmp_root,
        "brv-source-recording",
        "recording-verify",
        "failed",
    );
    create_manifest_sandbox(&tmp_root, "bmux-sbx-source-cli", "sandbox-cli", "succeeded");

    let output = sandbox
        .command()
        .args([
            "sandbox", "list", "--source", "playbook", "--limit", "50", "--json",
        ])
        .output()
        .expect("run sandbox list with source filter");
    assert!(
        output.status.success(),
        "sandbox list should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    let sandboxes = json["sandboxes"]
        .as_array()
        .expect("sandbox list should include sandboxes array");
    assert_eq!(sandboxes.len(), 1, "only playbook source should be listed");
    assert_eq!(sandboxes[0]["source"].as_str(), Some("playbook"));
    assert_eq!(sandboxes[0]["id"].as_str(), Some("bpb-source-playbook"));
}

#[test]
#[serial]
fn sandbox_cleanup_source_filter_only_reports_requested_source() {
    let sandbox = CommandSandbox::new("cleanup-source-filter");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bpb-cleanup-playbook", "playbook", "failed");
    create_manifest_sandbox(
        &tmp_root,
        "brv-cleanup-recording",
        "recording-verify",
        "failed",
    );

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "cleanup",
            "--dry-run",
            "--older-than",
            "0",
            "--source",
            "recording-verify",
            "--json",
        ])
        .output()
        .expect("run sandbox cleanup with source filter");
    assert!(
        output.status.success(),
        "sandbox cleanup should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    let entries = json["entries"]
        .as_array()
        .expect("sandbox cleanup should include entries array");
    assert_eq!(entries.len(), 2, "cleanup should report filtered decisions");
    assert_eq!(json["skipped_source_mismatch"].as_u64(), Some(1));
    let matched = entries
        .iter()
        .find(|entry| entry["source"].as_str() == Some("recording-verify"))
        .expect("cleanup should include recording-verify entry");
    assert_eq!(matched["reason"].as_str(), Some("would_remove"));
    let path = matched["path"]
        .as_str()
        .expect("cleanup entry path should be present");
    assert!(
        path.contains("brv-cleanup-recording"),
        "cleanup entry should point to recording sandbox"
    );
}

#[test]
#[serial]
fn sandbox_inspect_latest_source_filter_resolves_matching_source() {
    let sandbox = CommandSandbox::new("inspect-source-filter");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bpb-inspect-playbook", "playbook", "succeeded");
    create_manifest_sandbox(
        &tmp_root,
        "brv-inspect-recording",
        "recording-verify",
        "failed",
    );

    let output = sandbox
        .command()
        .args([
            "sandbox", "inspect", "--latest", "--source", "playbook", "--json",
        ])
        .output()
        .expect("run sandbox inspect latest with source filter");
    assert!(
        output.status.success(),
        "sandbox inspect should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    let id = json["manifest"]["id"]
        .as_str()
        .expect("sandbox inspect should include manifest id");
    assert_eq!(id, "bpb-inspect-playbook");
    assert_eq!(json["manifest"]["source"].as_str(), Some("playbook"));
}

#[test]
#[serial]
fn sandbox_cleanup_uses_config_defaults_when_flags_are_omitted() {
    let sandbox = CommandSandbox::new("cleanup-config-defaults");
    sandbox.write_config(
        "[sandbox.cleanup]\nfailed_only = true\nolder_than_secs = 0\nsource = 'recording_verify'\n",
    );

    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "brv-config-failed", "recording-verify", "failed");
    create_manifest_sandbox(
        &tmp_root,
        "brv-config-succeeded",
        "recording-verify",
        "succeeded",
    );
    create_manifest_sandbox(&tmp_root, "bpb-config-failed", "playbook", "failed");

    let output = sandbox
        .command()
        .args(["sandbox", "cleanup", "--dry-run", "--json"])
        .output()
        .expect("run sandbox cleanup with config defaults");
    assert!(
        output.status.success(),
        "sandbox cleanup should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    let entries = json["entries"]
        .as_array()
        .expect("sandbox cleanup should include entries array");
    assert_eq!(
        entries.len(),
        3,
        "cleanup should report matched and skipped decisions"
    );
    assert_eq!(json["skipped_source_mismatch"].as_u64(), Some(1));
    assert_eq!(json["skipped_not_failed"].as_u64(), Some(1));
    assert_eq!(json["orphaned"].as_u64(), Some(1));
}

#[test]
#[serial]
fn sandbox_cleanup_cli_flags_override_config_defaults() {
    let sandbox = CommandSandbox::new("cleanup-config-overrides");
    sandbox.write_config(
        "[sandbox.cleanup]\nfailed_only = true\nolder_than_secs = 86_400\nsource = 'recording_verify'\n",
    );

    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bpb-config-override-succeeded",
        "playbook",
        "succeeded",
    );

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "cleanup",
            "--dry-run",
            "--all-status",
            "--older-than",
            "0",
            "--source",
            "playbook",
            "--json",
        ])
        .output()
        .expect("run sandbox cleanup with explicit overrides");
    assert!(
        output.status.success(),
        "sandbox cleanup should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    let entries = json["entries"]
        .as_array()
        .expect("sandbox cleanup should include entries array");
    assert_eq!(
        entries.len(),
        1,
        "explicit flags should include playbook entry"
    );
    assert_eq!(entries[0]["source"].as_str(), Some("playbook"));
    assert_eq!(entries[0]["status"].as_str(), Some("stopped"));
    assert_eq!(entries[0]["reason"].as_str(), Some("would_remove"));
}

#[test]
#[serial]
fn sandbox_clean_uses_failed_only_defaults() {
    let sandbox = CommandSandbox::new("clean-defaults");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-clean-failed", "sandbox-cli", "failed");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-clean-succeeded",
        "sandbox-cli",
        "succeeded",
    );

    write_index_entries(
        &sandbox.sandbox_index_path(),
        serde_json::json!([
            {
                "id": "bmux-sbx-clean-failed",
                "root": tmp_root.join("bmux-sbx-clean-failed").to_string_lossy().to_string(),
                "source": "sandbox-cli",
                "status": "failed",
                "created_at_unix_ms": 1,
                "updated_at_unix_ms": 1,
                "last_seen_unix_ms": 1
            },
            {
                "id": "bmux-sbx-clean-succeeded",
                "root": tmp_root
                    .join("bmux-sbx-clean-succeeded")
                    .to_string_lossy()
                    .to_string(),
                "source": "sandbox-cli",
                "status": "succeeded",
                "created_at_unix_ms": 1,
                "updated_at_unix_ms": 1,
                "last_seen_unix_ms": 1
            }
        ]),
    );

    let output = sandbox
        .command()
        .args(["sandbox", "clean", "--dry-run", "--json"])
        .output()
        .expect("run sandbox clean with defaults");
    assert!(
        output.status.success(),
        "sandbox clean should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["orphaned"].as_u64(), Some(1));
    assert_eq!(json["skipped_not_failed"].as_u64(), Some(1));
    let entries = json["entries"]
        .as_array()
        .expect("sandbox clean should include entries array");
    assert_eq!(entries.len(), 2);
}

#[test]
#[serial]
fn sandbox_cleanup_failed_only_includes_aborted_running_manifests() {
    let sandbox = CommandSandbox::new("cleanup-aborted-running");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-aborted-running",
        "sandbox-cli",
        "running",
    );
    create_manifest_sandbox(&tmp_root, "bmux-sbx-succeeded", "sandbox-cli", "succeeded");

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "cleanup",
            "--dry-run",
            "--failed-only",
            "--older-than",
            "0",
            "--json",
        ])
        .output()
        .expect("run sandbox cleanup for aborted-running manifests");
    assert!(
        output.status.success(),
        "sandbox cleanup should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    let entries = json["entries"]
        .as_array()
        .expect("sandbox cleanup should include entries array");
    assert_eq!(
        entries.len(),
        2,
        "cleanup should include failed match and not_failed skip"
    );
    let failed_entry = entries
        .iter()
        .find(|entry| entry["status"].as_str() == Some("failed"))
        .expect("cleanup should include failed status entry");
    assert_eq!(failed_entry["reason"].as_str(), Some("would_remove"));
    let path = failed_entry["path"]
        .as_str()
        .expect("cleanup entry should include path");
    assert!(path.contains("bmux-sbx-aborted-running"));
}

#[test]
#[serial]
fn sandbox_cleanup_reports_missing_manifest_reason() {
    let sandbox = CommandSandbox::new("cleanup-missing-manifest-reason");
    let tmp_root = sandbox.root.path().join("tmp-root");
    let missing_manifest = tmp_root.join("bmux-sbx-missing-manifest");
    std::fs::create_dir_all(missing_manifest.join("logs")).expect("create logs dir");
    std::fs::create_dir_all(missing_manifest.join("runtime")).expect("create runtime dir");
    std::fs::create_dir_all(missing_manifest.join("state")).expect("create state dir");

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "cleanup",
            "--dry-run",
            "--failed-only",
            "--older-than",
            "0",
            "--json",
        ])
        .output()
        .expect("run sandbox cleanup for missing-manifest reason");
    assert!(
        output.status.success(),
        "sandbox cleanup should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["skipped_missing_manifest"].as_u64(), Some(1));
    let entries = json["entries"]
        .as_array()
        .expect("sandbox cleanup should include entries array");
    let missing_manifest_entry = entries
        .iter()
        .find(|entry| entry["reason"].as_str() == Some("missing_manifest"))
        .expect("cleanup should include missing_manifest reason");
    assert_eq!(
        missing_manifest_entry["path"]
            .as_str()
            .expect("entry path should be present"),
        missing_manifest.to_string_lossy()
    );
}

#[test]
#[serial]
fn sandbox_run_spawn_failure_marks_manifest_aborted() {
    let sandbox = CommandSandbox::new("spawn-failure-aborted");
    let non_exec = sandbox.root.path().join("not-executable-bmux");
    std::fs::write(&non_exec, "#!/bin/sh\nexit 0\n").expect("write non-executable file");

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        let mut permissions = std::fs::metadata(&non_exec)
            .expect("stat non-executable file")
            .permissions();
        permissions.set_mode(0o644);
        std::fs::set_permissions(&non_exec, permissions).expect("set non-executable permissions");
    }

    let run_output = sandbox
        .command()
        .args([
            "sandbox",
            "run",
            "--name",
            "spawn-failure",
            "--bmux-bin",
            non_exec.to_string_lossy().as_ref(),
            "--",
            "--version",
        ])
        .output()
        .expect("run sandbox with non-executable bmux binary");
    assert!(
        !run_output.status.success(),
        "sandbox run should fail to spawn: {}",
        String::from_utf8_lossy(&run_output.stderr)
    );

    let inspect_output = sandbox
        .command()
        .args(["sandbox", "inspect", "--latest-failed", "--json"])
        .output()
        .expect("inspect latest failed sandbox after spawn error");
    assert!(
        inspect_output.status.success(),
        "inspect latest failed should succeed: {}",
        String::from_utf8_lossy(&inspect_output.stderr)
    );

    let inspect_json = parse_json_stdout(&inspect_output);
    assert_schema_version(&inspect_json);
    assert_eq!(inspect_json["manifest"]["status"].as_str(), Some("aborted"));
    assert_eq!(inspect_json["running"].as_bool(), Some(false));
}

#[test]
#[serial]
fn sandbox_list_falls_back_to_scan_when_index_is_corrupt() {
    let sandbox = CommandSandbox::new("index-corrupt-fallback");
    let index_path = sandbox.sandbox_index_path();
    std::fs::create_dir_all(
        index_path
            .parent()
            .expect("sandbox index parent should exist"),
    )
    .expect("create sandbox index directory");
    std::fs::write(index_path, b"{not-json").expect("write corrupt sandbox index");

    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-index-fallback",
        "sandbox-cli",
        "succeeded",
    );

    let output = sandbox
        .command()
        .args(["sandbox", "list", "--limit", "50", "--json"])
        .output()
        .expect("run sandbox list with corrupt index");
    assert!(
        output.status.success(),
        "sandbox list should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(
        json["reconcile"]["scan_fallback_used"].as_bool(),
        Some(true)
    );
    assert!(
        json["reconcile"]["healed_entries"].as_u64().unwrap_or(0) >= 1,
        "corrupt index fallback should report healed entries"
    );
    let sandboxes = json["sandboxes"]
        .as_array()
        .expect("sandbox list should include sandboxes array");
    assert!(
        sandboxes
            .iter()
            .any(|entry| entry["id"].as_str() == Some("bmux-sbx-index-fallback")),
        "list should fall back to temp scan when index is corrupt"
    );
}

#[test]
#[serial]
fn sandbox_cleanup_removes_deleted_index_entry() {
    let sandbox = CommandSandbox::new("index-cleanup-prune");
    let tmp_root = sandbox.root.path().join("tmp-root");
    let sandbox_dir_name = "bmux-sbx-index-cleanup-delete";
    create_manifest_sandbox(&tmp_root, sandbox_dir_name, "sandbox-cli", "failed");
    let sandbox_path = tmp_root.join(sandbox_dir_name);
    let sandbox_path_string = sandbox_path.to_string_lossy().to_string();

    write_index_entries(
        &sandbox.sandbox_index_path(),
        serde_json::json!([
            {
                "id": sandbox_dir_name,
                "root": sandbox_path_string,
                "source": "sandbox-cli",
                "status": "failed",
                "created_at_unix_ms": 1,
                "updated_at_unix_ms": 1,
                "last_seen_unix_ms": 1
            }
        ]),
    );

    let cleanup_output = sandbox
        .command()
        .args([
            "sandbox",
            "cleanup",
            "--failed-only",
            "--older-than",
            "0",
            "--json",
        ])
        .output()
        .expect("run sandbox cleanup to remove indexed sandbox");
    assert!(
        cleanup_output.status.success(),
        "sandbox cleanup should succeed: {}",
        String::from_utf8_lossy(&cleanup_output.stderr)
    );

    let cleanup_json = parse_json_stdout(&cleanup_output);
    assert_schema_version(&cleanup_json);
    let entries = cleanup_json["entries"]
        .as_array()
        .expect("sandbox cleanup should include entries array");
    assert_eq!(entries.len(), 1, "cleanup should remove one sandbox");
    assert_eq!(entries[0]["removed"].as_bool(), Some(true));
    assert!(
        !sandbox_path.exists(),
        "sandbox directory should be removed"
    );

    let index_contents = std::fs::read_to_string(sandbox.sandbox_index_path())
        .expect("read sandbox index after cleanup");
    let index_json: serde_json::Value =
        serde_json::from_str(&index_contents).expect("parse sandbox index json");
    let index_entries = index_json["entries"]
        .as_array()
        .expect("index should contain entries array");
    assert!(index_entries.is_empty(), "cleanup should prune index entry");
}

#[test]
#[serial]
fn sandbox_inspect_latest_prefers_index_updated_timestamp_ordering() {
    let sandbox = CommandSandbox::new("index-latest-ordering");
    let tmp_root = sandbox.root.path().join("tmp-root");

    create_manifest_sandbox(&tmp_root, "bmux-sbx-index-old", "sandbox-cli", "succeeded");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-index-new", "sandbox-cli", "succeeded");

    write_index_entries(
        &sandbox.sandbox_index_path(),
        serde_json::json!([
            {
                "id": "bmux-sbx-index-old",
                "root": tmp_root.join("bmux-sbx-index-old").to_string_lossy().to_string(),
                "source": "sandbox-cli",
                "status": "succeeded",
                "created_at_unix_ms": 1,
                "updated_at_unix_ms": 10,
                "last_seen_unix_ms": 10
            },
            {
                "id": "bmux-sbx-index-new",
                "root": tmp_root.join("bmux-sbx-index-new").to_string_lossy().to_string(),
                "source": "sandbox-cli",
                "status": "succeeded",
                "created_at_unix_ms": 1,
                "updated_at_unix_ms": 20,
                "last_seen_unix_ms": 20
            }
        ]),
    );

    let output = sandbox
        .command()
        .args(["sandbox", "inspect", "--latest", "--json"])
        .output()
        .expect("run sandbox inspect latest with indexed order");
    assert!(
        output.status.success(),
        "sandbox inspect should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(
        json["manifest"]["id"].as_str(),
        Some("bmux-sbx-index-new"),
        "latest inspect should pick highest index updated_at"
    );
}

#[test]
#[serial]
fn sandbox_list_prunes_missing_index_entries_during_reconcile() {
    let sandbox = CommandSandbox::new("index-prune-missing-roots");
    let tmp_root = sandbox.root.path().join("tmp-root");

    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-index-existing",
        "sandbox-cli",
        "succeeded",
    );

    write_index_entries(
        &sandbox.sandbox_index_path(),
        serde_json::json!([
            {
                "id": "bmux-sbx-index-missing",
                "root": tmp_root.join("bmux-sbx-index-missing").to_string_lossy().to_string(),
                "source": "sandbox-cli",
                "status": "succeeded",
                "created_at_unix_ms": 1,
                "updated_at_unix_ms": 5,
                "last_seen_unix_ms": 5
            },
            {
                "id": "bmux-sbx-index-existing",
                "root": tmp_root
                    .join("bmux-sbx-index-existing")
                    .to_string_lossy()
                    .to_string(),
                "source": "sandbox-cli",
                "status": "succeeded",
                "created_at_unix_ms": 1,
                "updated_at_unix_ms": 10,
                "last_seen_unix_ms": 10
            }
        ]),
    );

    let output = sandbox
        .command()
        .args(["sandbox", "list", "--limit", "50", "--json"])
        .output()
        .expect("run sandbox list for index reconcile");
    assert!(
        output.status.success(),
        "sandbox list should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    let sandboxes = json["sandboxes"]
        .as_array()
        .expect("sandbox list should include sandboxes array");
    assert_eq!(sandboxes.len(), 1, "only existing sandbox should remain");
    assert_eq!(sandboxes[0]["id"].as_str(), Some("bmux-sbx-index-existing"));

    let index_contents = std::fs::read_to_string(sandbox.sandbox_index_path())
        .expect("read sandbox index after list reconcile");
    let index_json: serde_json::Value =
        serde_json::from_str(&index_contents).expect("parse sandbox index json");
    let index_entries = index_json["entries"]
        .as_array()
        .expect("index should contain entries array");
    assert_eq!(
        index_entries.len(),
        1,
        "missing index entry should be pruned"
    );
    assert_eq!(
        index_entries[0]["id"].as_str(),
        Some("bmux-sbx-index-existing")
    );
}

#[test]
#[serial]
fn sandbox_rebuild_index_recreates_missing_index() {
    let sandbox = CommandSandbox::new("rebuild-index-missing");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-rebuild-a", "sandbox-cli", "succeeded");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-rebuild-b", "playbook", "failed");

    let output = sandbox
        .command()
        .args(["sandbox", "rebuild-index", "--json"])
        .output()
        .expect("run sandbox rebuild-index with missing index");
    assert!(
        output.status.success(),
        "sandbox rebuild-index should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["rebuilt_count"].as_u64(), Some(2));
    assert_eq!(json["missing_manifest"].as_u64(), Some(0));

    let index_contents =
        std::fs::read_to_string(sandbox.sandbox_index_path()).expect("read rebuilt sandbox index");
    let index_json: serde_json::Value =
        serde_json::from_str(&index_contents).expect("parse sandbox index json");
    let index_entries = index_json["entries"]
        .as_array()
        .expect("index should contain entries array");
    assert_eq!(
        index_entries.len(),
        2,
        "rebuild should write both manifests"
    );
}

#[test]
#[serial]
fn sandbox_rebuild_index_recovers_from_corrupt_index() {
    let sandbox = CommandSandbox::new("rebuild-index-corrupt");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-rebuild-corrupt",
        "recording-verify",
        "failed",
    );

    let index_path = sandbox.sandbox_index_path();
    std::fs::create_dir_all(
        index_path
            .parent()
            .expect("sandbox index parent should exist"),
    )
    .expect("create sandbox index directory");
    std::fs::write(&index_path, b"{not-json").expect("write corrupt index file");

    let output = sandbox
        .command()
        .args(["sandbox", "rebuild-index", "--json"])
        .output()
        .expect("run sandbox rebuild-index with corrupt index");
    assert!(
        output.status.success(),
        "sandbox rebuild-index should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["rebuilt_count"].as_u64(), Some(1));
    assert_eq!(json["scan_fallback_used"].as_bool(), Some(true));

    let index_contents = std::fs::read_to_string(index_path).expect("read repaired sandbox index");
    let index_json: serde_json::Value =
        serde_json::from_str(&index_contents).expect("parse repaired index json");
    let index_entries = index_json["entries"]
        .as_array()
        .expect("index should contain entries array");
    assert_eq!(
        index_entries.len(),
        1,
        "rebuild should repair corrupt index"
    );
    assert_eq!(
        index_entries[0]["id"].as_str(),
        Some("bmux-sbx-rebuild-corrupt")
    );
}

#[test]
#[serial]
fn sandbox_parallel_runs_and_cleanup_keep_index_and_locks_consistent() {
    let sandbox = CommandSandbox::new("parallel-runs-cleanup");
    let root = sandbox.root.path().to_path_buf();
    let run_threads = 4usize;

    let mut handles = Vec::new();
    for index in 0..run_threads {
        let root_clone = root.clone();
        handles.push(thread::spawn(move || {
            let name = format!("parallel-run-{index}");
            sandbox_command_for_root(&root_clone)
                .args([
                    "sandbox",
                    "run",
                    "--keep",
                    "--name",
                    &name,
                    "--",
                    "--version",
                ])
                .output()
                .expect("run sandbox command in parallel")
        }));
    }

    let cleanup_root = root.clone();
    let cleanup_handle = thread::spawn(move || {
        for _ in 0..12 {
            let output = sandbox_command_for_root(&cleanup_root)
                .args([
                    "sandbox",
                    "cleanup",
                    "--dry-run",
                    "--older-than",
                    "0",
                    "--json",
                ])
                .output()
                .expect("run cleanup while runs are active");
            assert!(
                output.status.success(),
                "cleanup should succeed during parallel runs: {}",
                String::from_utf8_lossy(&output.stderr)
            );
            thread::sleep(Duration::from_millis(15));
        }
    });

    for handle in handles {
        let output = handle.join().expect("join run thread");
        assert!(
            output.status.success(),
            "parallel sandbox run should succeed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
    cleanup_handle.join().expect("join cleanup thread");

    let tmp_root = root.join("tmp-root");
    for entry in std::fs::read_dir(&tmp_root).expect("read tmp-root entries") {
        let path = entry.expect("read tmp-root entry path").path();
        if !path.is_dir() {
            continue;
        }
        let Some(name) = path.file_name() else {
            continue;
        };
        let name = name.to_string_lossy();
        if name.starts_with("bmux-sbx-") || name.starts_with("bpb-") || name.starts_with("brv-") {
            assert!(
                !path.join("sandbox.lock").exists(),
                "completed sandbox should not keep stale lock: {}",
                path.display()
            );
        }
    }

    let list_output = sandbox
        .command()
        .args(["sandbox", "list", "--limit", "100", "--json"])
        .output()
        .expect("run sandbox list after parallel operations");
    assert!(
        list_output.status.success(),
        "sandbox list should succeed: {}",
        String::from_utf8_lossy(&list_output.stderr)
    );
    let list_json = parse_json_stdout(&list_output);
    assert_schema_version(&list_json);
    let entries = list_json["sandboxes"]
        .as_array()
        .expect("sandbox list should include sandboxes array");
    assert!(
        entries.len() >= run_threads,
        "list should include completed parallel sandboxes"
    );

    let rebuild_output = sandbox
        .command()
        .args(["sandbox", "rebuild-index", "--json"])
        .output()
        .expect("rebuild index after parallel operations");
    assert!(
        rebuild_output.status.success(),
        "rebuild-index should succeed: {}",
        String::from_utf8_lossy(&rebuild_output.stderr)
    );
    let rebuild_json = parse_json_stdout(&rebuild_output);
    assert_schema_version(&rebuild_json);
    assert!(
        rebuild_json["rebuilt_count"].as_u64().unwrap_or(0) >= run_threads as u64,
        "rebuilt index should retain parallel sandbox entries"
    );
}

#[test]
#[serial]
fn sandbox_status_reports_source_counts_and_health() {
    let sandbox = CommandSandbox::new("status-summary");
    let tmp_root = sandbox.root.path().join("tmp-root");

    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-status-running",
        "sandbox-cli",
        "running",
    );
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-status-failed",
        "recording-verify",
        "failed",
    );
    create_manifest_sandbox(&tmp_root, "bpb-status-stopped", "playbook", "succeeded");

    let stale_lock_root = tmp_root.join("bmux-sbx-status-running");
    write_stale_lock(&stale_lock_root, 999_999);

    let output = sandbox
        .command()
        .args(["sandbox", "status", "--json"])
        .output()
        .expect("run sandbox status");
    assert!(
        output.status.success(),
        "sandbox status should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["totals"]["total"].as_u64(), Some(3));
    assert_eq!(json["totals"]["failed"].as_u64(), Some(2));
    assert_eq!(json["totals"]["stopped"].as_u64(), Some(1));

    let by_source = json["by_source"]
        .as_array()
        .expect("status json should include by_source array");
    assert_eq!(by_source.len(), 3);
    assert!(
        by_source
            .iter()
            .any(|source| source["source"].as_str() == Some("sandbox-cli")
                && source["failed"].as_u64() == Some(1))
    );
    assert!(by_source.iter().any(
        |source| source["source"].as_str() == Some("recording-verify")
            && source["failed"].as_u64() == Some(1)
    ));
    assert!(
        by_source
            .iter()
            .any(|source| source["source"].as_str() == Some("playbook")
                && source["stopped"].as_u64() == Some(1))
    );

    assert_eq!(json["health"]["stale_lock_count"].as_u64(), Some(0));
    assert_eq!(json["health"]["index_exists"].as_bool(), Some(true));
    assert_eq!(
        json["reconcile"]["scan_fallback_used"].as_bool(),
        Some(true)
    );
    assert!(
        json["reconcile"]["healed_entries"].as_u64().unwrap_or(0) >= 1,
        "status should surface reconcile heal count"
    );
    assert_eq!(
        json["reconcile"]["normalized_running"].as_u64(),
        Some(1),
        "status recovery should normalize stale running manifest"
    );
    assert_eq!(
        json["reconcile"]["cleared_stale_locks"].as_u64(),
        Some(1),
        "status recovery should clear stale lock files"
    );

    let recovered_manifest_path = stale_lock_root.join("sandbox.json");
    let recovered_manifest =
        std::fs::read_to_string(&recovered_manifest_path).expect("read recovered manifest");
    let recovered_json: serde_json::Value =
        serde_json::from_str(&recovered_manifest).expect("parse recovered manifest json");
    assert_eq!(recovered_json["status"].as_str(), Some("aborted"));
    assert!(
        !stale_lock_root.join("sandbox.lock").exists(),
        "stale lock should be removed during recovery"
    );
}

#[test]
#[serial]
fn sandbox_inspect_reports_reconcile_when_auto_heal_runs() {
    let sandbox = CommandSandbox::new("inspect-reconcile");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-inspect-reconcile",
        "sandbox-cli",
        "failed",
    );

    let index_path = sandbox.sandbox_index_path();
    std::fs::create_dir_all(
        index_path
            .parent()
            .expect("sandbox index parent should exist"),
    )
    .expect("create sandbox index directory");
    std::fs::write(index_path, b"{broken").expect("write corrupt index");

    let output = sandbox
        .command()
        .args(["sandbox", "inspect", "--latest-failed", "--json"])
        .output()
        .expect("run sandbox inspect latest-failed with reconcile");
    assert!(
        output.status.success(),
        "sandbox inspect should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(
        json["manifest"]["id"].as_str(),
        Some("bmux-sbx-inspect-reconcile")
    );
    assert_eq!(
        json["reconcile"]["scan_fallback_used"].as_bool(),
        Some(true)
    );
    assert!(
        json["reconcile"]["healed_entries"].as_u64().unwrap_or(0) >= 1,
        "inspect should surface reconcile heal count"
    );
}

#[test]
#[serial]
fn sandbox_doctor_fix_dry_run_reports_repairs_without_mutation() {
    let sandbox = CommandSandbox::new("doctor-fix-dry-run");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-doctor-dry", "sandbox-cli", "running");
    let root = tmp_root.join("bmux-sbx-doctor-dry");
    write_stale_lock(&root, 999_999);

    let output = sandbox
        .command()
        .args(["sandbox", "doctor", "--fix", "--dry-run", "--json"])
        .output()
        .expect("run sandbox doctor --fix --dry-run");
    assert!(
        output.status.success(),
        "sandbox doctor should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["fix"]["applied"].as_bool(), Some(false));
    assert_eq!(json["fix"]["dry_run"].as_bool(), Some(true));
    assert_eq!(json["fix"]["normalized_running"].as_u64(), Some(1));
    assert_eq!(json["fix"]["cleared_stale_locks"].as_u64(), Some(1));

    let manifest_contents = std::fs::read_to_string(root.join("sandbox.json"))
        .expect("read manifest after dry-run doctor fix");
    let manifest_json: serde_json::Value =
        serde_json::from_str(&manifest_contents).expect("parse manifest json");
    assert_eq!(manifest_json["status"].as_str(), Some("running"));
    assert!(
        root.join("sandbox.lock").exists(),
        "dry-run fix should not remove stale lock"
    );
}

#[test]
#[serial]
fn sandbox_doctor_fix_applies_recovery_and_rebuilds_index() {
    let sandbox = CommandSandbox::new("doctor-fix-apply");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-doctor-apply", "sandbox-cli", "running");
    let root = tmp_root.join("bmux-sbx-doctor-apply");
    write_stale_lock(&root, 999_999);

    let index_path = sandbox.sandbox_index_path();
    std::fs::create_dir_all(
        index_path
            .parent()
            .expect("sandbox index parent should exist"),
    )
    .expect("create sandbox index parent dir");
    std::fs::write(&index_path, b"{bad-json").expect("write corrupt index");

    let output = sandbox
        .command()
        .args(["sandbox", "doctor", "--fix", "--json"])
        .output()
        .expect("run sandbox doctor --fix");
    assert!(
        output.status.success(),
        "sandbox doctor should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["fix"]["applied"].as_bool(), Some(true));
    assert_eq!(json["fix"]["dry_run"].as_bool(), Some(false));
    assert_eq!(json["fix"]["normalized_running"].as_u64(), Some(1));
    assert_eq!(json["fix"]["cleared_stale_locks"].as_u64(), Some(1));
    assert_eq!(json["fix"]["index_rebuilt"].as_bool(), Some(true));
    assert!(
        json["fix"]["rebuilt_count"].as_u64().unwrap_or(0) >= 1,
        "doctor fix should rebuild index entries"
    );

    let manifest_contents =
        std::fs::read_to_string(root.join("sandbox.json")).expect("read manifest after doctor fix");
    let manifest_json: serde_json::Value =
        serde_json::from_str(&manifest_contents).expect("parse manifest json");
    assert_eq!(manifest_json["status"].as_str(), Some("aborted"));
    assert!(
        !root.join("sandbox.lock").exists(),
        "doctor fix should clear stale lock"
    );

    let index_contents = std::fs::read_to_string(index_path).expect("read repaired index");
    let index_json: serde_json::Value =
        serde_json::from_str(&index_contents).expect("parse repaired index json");
    let entries = index_json["entries"]
        .as_array()
        .expect("index should contain entries array");
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0]["id"].as_str(), Some("bmux-sbx-doctor-apply"));
}

#[test]
#[serial]
fn sandbox_tail_returns_log_lines_for_target() {
    let sandbox = CommandSandbox::new("tail-shortcut");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-tail-target", "sandbox-cli", "failed");
    let log_path = tmp_root
        .join("bmux-sbx-tail-target")
        .join("logs")
        .join("run.log");
    std::fs::write(&log_path, "line-a\nline-b\nline-c\n").expect("write sandbox log file");

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "tail",
            "bmux-sbx-tail-target",
            "--tail",
            "2",
            "--json",
        ])
        .output()
        .expect("run sandbox tail");
    assert!(
        output.status.success(),
        "sandbox tail should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["id"].as_str(), Some("bmux-sbx-tail-target"));
    assert_eq!(json["source"].as_str(), Some("sandbox-cli"));
    assert_eq!(json["status"].as_str(), Some("failed"));
    let log_tail = json["log_tail"]
        .as_array()
        .expect("tail output should include log_tail array");
    assert_eq!(log_tail.len(), 2);
    assert_eq!(log_tail[0].as_str(), Some("line-b"));
    assert_eq!(log_tail[1].as_str(), Some("line-c"));
}

#[test]
#[serial]
fn sandbox_open_returns_paths_and_repro() {
    let sandbox = CommandSandbox::new("open-shortcut");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-open-target", "sandbox-cli", "failed");
    let log_path = tmp_root
        .join("bmux-sbx-open-target")
        .join("logs")
        .join("run.log");
    std::fs::write(&log_path, "open-log\n").expect("write sandbox log file");

    let output = sandbox
        .command()
        .args(["sandbox", "open", "bmux-sbx-open-target", "--json"])
        .output()
        .expect("run sandbox open");
    assert!(
        output.status.success(),
        "sandbox open should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["id"].as_str(), Some("bmux-sbx-open-target"));
    assert_eq!(json["source"].as_str(), Some("sandbox-cli"));
    assert_eq!(json["status"].as_str(), Some("failed"));
    let root = json["root"]
        .as_str()
        .expect("open output should include root");
    assert!(root.contains("bmux-sbx-open-target"));
    assert_eq!(
        json["latest_log"].as_str(),
        Some(log_path.to_string_lossy().as_ref())
    );
    assert!(
        json["repro"]
            .as_str()
            .unwrap_or_default()
            .contains("--version"),
        "open output should include repro command"
    );
}

#[test]
#[serial]
fn sandbox_rerun_executes_command_from_manifest() {
    let sandbox = CommandSandbox::new("rerun-shortcut");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-rerun-target", "sandbox-cli", "failed");
    let bmux_bin = bmux_binary();
    let bmux_bin_arg = bmux_bin.to_string_lossy().to_string();

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "rerun",
            "bmux-sbx-rerun-target",
            "--bmux-bin",
            bmux_bin_arg.as_str(),
            "--json",
        ])
        .output()
        .expect("run sandbox rerun");
    assert!(
        output.status.success(),
        "sandbox rerun should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["status"].as_str(), Some("succeeded"));
}

#[test]
#[serial]
fn sandbox_tail_requires_target_or_selector() {
    let sandbox = CommandSandbox::new("tail-target-required");

    let output = sandbox
        .command()
        .args(["sandbox", "tail", "--json"])
        .output()
        .expect("run sandbox tail without target");

    assert!(
        !output.status.success(),
        "sandbox tail should fail when no target selector is provided"
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("tail target required"),
        "tail failure should provide target selector guidance: {stderr}"
    );
}

#[test]
#[serial]
fn sandbox_open_json_reports_missing_latest_log_as_null() {
    let sandbox = CommandSandbox::new("open-missing-latest-log");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-open-missing-log",
        "sandbox-cli",
        "failed",
    );

    let output = sandbox
        .command()
        .args(["sandbox", "open", "bmux-sbx-open-missing-log", "--json"])
        .output()
        .expect("run sandbox open without log files");
    assert!(
        output.status.success(),
        "sandbox open should succeed without logs: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert!(
        json["latest_log"].is_null(),
        "latest_log should be null when no log files are present"
    );
}

#[test]
#[serial]
fn sandbox_rerun_fails_when_manifest_command_is_empty() {
    let sandbox = CommandSandbox::new("rerun-empty-command");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-rerun-empty-command",
        "sandbox-cli",
        "failed",
    );

    let manifest_path = tmp_root
        .join("bmux-sbx-rerun-empty-command")
        .join("sandbox.json");
    let mut manifest: serde_json::Value = serde_json::from_slice(
        &std::fs::read(&manifest_path).expect("read manifest for empty command test"),
    )
    .expect("parse manifest json");
    manifest["command"] = serde_json::json!([]);
    std::fs::write(
        &manifest_path,
        serde_json::to_vec_pretty(&manifest).expect("serialize mutated manifest"),
    )
    .expect("write mutated manifest");

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "rerun",
            "bmux-sbx-rerun-empty-command",
            "--bmux-bin",
            bmux_binary().to_string_lossy().as_ref(),
        ])
        .output()
        .expect("run sandbox rerun with empty command manifest");

    assert!(
        !output.status.success(),
        "sandbox rerun should fail for empty command manifest"
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("has no command to rerun"),
        "rerun failure should explain missing command: {stderr}"
    );
}

#[test]
#[serial]
fn sandbox_triage_defaults_to_latest_failed_and_reports_target() {
    let sandbox = CommandSandbox::new("triage-default-latest-failed");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-triage-target", "sandbox-cli", "failed");
    let log_path = tmp_root
        .join("bmux-sbx-triage-target")
        .join("logs")
        .join("run.log");
    std::fs::write(&log_path, "triage-line\n").expect("write triage log file");

    let output = sandbox
        .command()
        .args(["sandbox", "triage", "--tail", "10", "--json"])
        .output()
        .expect("run sandbox triage with defaults");
    assert!(
        output.status.success(),
        "sandbox triage should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["selection"]["defaulted_to_latest_failed"], true);
    assert_eq!(
        json["target"]["id"].as_str(),
        Some("bmux-sbx-triage-target")
    );
    assert_eq!(json["target"]["source"].as_str(), Some("sandbox-cli"));
    assert_eq!(json["target"]["status"].as_str(), Some("failed"));
    assert_eq!(json["bundle"]["requested"], false);
    assert_eq!(json["bundle"]["executed"], false);
    assert_eq!(json["rerun"]["requested"], false);
    assert_eq!(json["rerun"]["executed"], false);
}

#[test]
#[serial]
fn sandbox_triage_bundle_generates_verified_bundle_json() {
    let sandbox = CommandSandbox::new("triage-bundle-json");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-triage-bundle", "sandbox-cli", "failed");
    let log_path = tmp_root
        .join("bmux-sbx-triage-bundle")
        .join("logs")
        .join("run.log");
    std::fs::write(&log_path, "triage-bundle\n").expect("write triage bundle log file");

    let bundle_output_root = sandbox.root.path().join("triage-bundles");
    let output = sandbox
        .command()
        .args([
            "sandbox",
            "triage",
            "bmux-sbx-triage-bundle",
            "--bundle",
            "--bundle-output",
            bundle_output_root.to_string_lossy().as_ref(),
            "--json",
        ])
        .output()
        .expect("run sandbox triage with bundle");
    assert!(
        output.status.success(),
        "sandbox triage --bundle should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["bundle"]["requested"].as_bool(), Some(true));
    assert_eq!(json["bundle"]["executed"].as_bool(), Some(true));
    assert_eq!(json["bundle"]["verify"]["ok"].as_bool(), Some(true));
    assert_eq!(json["bundle"]["verify"]["strict"].as_bool(), Some(false));
    assert_eq!(
        json["bundle"]["verify"]["mode"].as_str(),
        Some("strict_metadata")
    );
    assert_eq!(json["bundle"]["verify"]["issue_count"].as_u64(), Some(0));
    assert_eq!(
        json["bundle"]["verify"]["unexpected_artifacts"]
            .as_array()
            .map(Vec::len),
        Some(0)
    );
    assert_eq!(
        json["bundle"]["verify"]["version_check"]["ok"].as_bool(),
        Some(true)
    );
    let bundle_dir = json["bundle"]["bundle_dir"]
        .as_str()
        .expect("triage bundle output should include bundle_dir");
    assert!(
        Path::new(bundle_dir).is_dir(),
        "triage bundle directory should exist"
    );
}

#[test]
#[serial]
fn sandbox_triage_bundle_strict_verify_sets_strict_mode() {
    let sandbox = CommandSandbox::new("triage-bundle-strict");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-triage-bundle-strict",
        "sandbox-cli",
        "failed",
    );

    let bundle_output_root = sandbox.root.path().join("triage-bundles");
    let output = sandbox
        .command()
        .args([
            "sandbox",
            "triage",
            "bmux-sbx-triage-bundle-strict",
            "--bundle",
            "--bundle-output",
            bundle_output_root.to_string_lossy().as_ref(),
            "--bundle-strict-verify",
            "--json",
        ])
        .output()
        .expect("run sandbox triage with strict bundle verify");
    assert!(
        output.status.success(),
        "sandbox triage --bundle-strict-verify should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json = parse_json_stdout(&output);
    assert_schema_version(&json);
    assert_eq!(json["bundle"]["requested"].as_bool(), Some(true));
    assert_eq!(json["bundle"]["verify"]["strict"].as_bool(), Some(true));
    assert_eq!(json["bundle"]["verify"]["ok"].as_bool(), Some(true));
    assert_eq!(json["bundle"]["verify"]["issue_count"].as_u64(), Some(0));
    assert_eq!(
        json["bundle"]["verify"]["version_check"]["ok"].as_bool(),
        Some(true)
    );
}

#[test]
#[serial]
fn sandbox_triage_rerun_executes_manifest_command() {
    let sandbox = CommandSandbox::new("triage-rerun");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-triage-rerun", "sandbox-cli", "failed");

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "triage",
            "bmux-sbx-triage-rerun",
            "--rerun",
            "--bmux-bin",
            bmux_binary().to_string_lossy().as_ref(),
        ])
        .output()
        .expect("run sandbox triage with rerun");
    assert!(
        output.status.success(),
        "sandbox triage rerun should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("rerun_exit_code: 0"),
        "triage rerun output should include rerun exit code: {stdout}"
    );
}

#[test]
#[serial]
fn sandbox_inspect_rejects_ambiguous_prefix_target() {
    let sandbox = CommandSandbox::new("inspect-ambiguous-prefix");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-ambiguous-one", "sandbox-cli", "failed");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-ambiguous-two", "sandbox-cli", "failed");

    let output = sandbox
        .command()
        .args(["sandbox", "inspect", "bmux-sbx-ambiguous", "--json"])
        .output()
        .expect("run sandbox inspect with ambiguous prefix");
    assert!(
        !output.status.success(),
        "sandbox inspect should fail for ambiguous prefix"
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("is ambiguous"),
        "error should mention ambiguity: {stderr}"
    );
    assert!(
        stderr.contains("bmux-sbx-ambiguous-one") && stderr.contains("bmux-sbx-ambiguous-two"),
        "error should include matching ids: {stderr}"
    );
}

#[test]
#[serial]
fn sandbox_inspect_not_found_suggests_similar_target() {
    let sandbox = CommandSandbox::new("inspect-similar-suggest");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-suggest-target",
        "sandbox-cli",
        "failed",
    );

    let output = sandbox
        .command()
        .args(["sandbox", "inspect", "suggest-target", "--json"])
        .output()
        .expect("run sandbox inspect with near-miss target");
    assert!(
        !output.status.success(),
        "sandbox inspect should fail for unknown target"
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("did you mean") && stderr.contains("bmux-sbx-suggest-target"),
        "error should suggest a similar sandbox id: {stderr}"
    );
}

#[test]
#[serial]
fn sandbox_latest_source_error_lists_available_sources() {
    let sandbox = CommandSandbox::new("latest-source-hint");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(&tmp_root, "bmux-sbx-playbook-only", "playbook", "failed");

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "inspect",
            "--latest",
            "--source",
            "recording-verify",
            "--json",
        ])
        .output()
        .expect("run sandbox inspect latest by missing source");
    assert!(
        !output.status.success(),
        "sandbox inspect should fail for missing source"
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("available sources: playbook"),
        "error should list available sources: {stderr}"
    );
}

#[test]
#[serial]
fn sandbox_latest_failed_source_error_suggests_latest() {
    let sandbox = CommandSandbox::new("latest-failed-source-hint");
    let tmp_root = sandbox.root.path().join("tmp-root");
    create_manifest_sandbox(
        &tmp_root,
        "bmux-sbx-playbook-stopped",
        "playbook",
        "stopped",
    );

    let output = sandbox
        .command()
        .args([
            "sandbox",
            "inspect",
            "--latest-failed",
            "--source",
            "playbook",
            "--json",
        ])
        .output()
        .expect("run sandbox inspect latest-failed by source without failures");
    assert!(
        !output.status.success(),
        "sandbox inspect should fail when source has no failed sandboxes"
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("try --latest --source playbook"),
        "error should suggest source-scoped latest helper: {stderr}"
    );
}
