#![cfg(feature = "integration-real-tests")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use std::path::PathBuf;
use tokio::process::Command;
use tokio::time::{Duration, timeout};

use meerkat::Config;
use tempfile::TempDir;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn rkat_binary_path() -> Option<PathBuf> {
    if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rkat") {
        let path = PathBuf::from(path);
        if path.exists() {
            return Some(path.canonicalize().unwrap_or(path));
        }
    }

    if let Some(target_dir) = std::env::var_os("CARGO_TARGET_DIR") {
        let target_dir = PathBuf::from(target_dir);
        let debug = target_dir.join("debug/rkat");
        if debug.exists() {
            return Some(debug);
        }
        let release = target_dir.join("release/rkat");
        if release.exists() {
            return Some(release);
        }
    }

    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let workspace_root = manifest_dir.parent()?;
    let codex_debug = workspace_root.join("target-codex/debug/rkat");
    if codex_debug.exists() {
        return Some(codex_debug);
    }
    let codex_release = workspace_root.join("target-codex/release/rkat");
    if codex_release.exists() {
        return Some(codex_release);
    }
    let debug = workspace_root.join("target/debug/rkat");
    if debug.exists() {
        return Some(debug);
    }
    let release = workspace_root.join("target/release/rkat");
    if release.exists() {
        return Some(release);
    }
    None
}

fn anthropic_api_key() -> Option<String> {
    first_env(&["RKAT_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"])
}

fn openai_api_key() -> Option<String> {
    first_env(&["RKAT_OPENAI_API_KEY", "OPENAI_API_KEY"])
}

fn first_env(vars: &[&str]) -> Option<String> {
    for name in vars {
        if let Ok(value) = std::env::var(name)
            && !value.is_empty()
        {
            return Some(value);
        }
    }
    None
}

fn smoke_model() -> String {
    std::env::var("SMOKE_MODEL").unwrap_or_else(|_| "claude-sonnet-4-5".to_string())
}

async fn read_jsonl_files_under(
    root: &std::path::Path,
) -> Result<String, Box<dyn std::error::Error>> {
    let mut stack = vec![root.to_path_buf()];
    let mut combined = String::new();
    while let Some(dir) = stack.pop() {
        let mut entries = match tokio::fs::read_dir(&dir).await {
            Ok(entries) => entries,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
            Err(err) => return Err(Box::new(err)),
        };
        while let Some(entry) = entries.next_entry().await? {
            let path = entry.path();
            let file_type = entry.file_type().await?;
            if file_type.is_dir() {
                stack.push(path);
            } else if path
                .extension()
                .and_then(|ext| ext.to_str())
                .is_some_and(|ext| ext == "jsonl")
            {
                combined.push_str(&tokio::fs::read_to_string(path).await?);
                combined.push('\n');
            }
        }
    }
    Ok(combined)
}

/// Returns `true` if prerequisites are missing and the test should be skipped.
fn skip_if_no_prereqs() -> bool {
    if rkat_binary_path().is_none() {
        eprintln!("Skipping: rkat binary not found (build with `cargo build -p meerkat-cli`)");
        return true;
    }
    false
}

/// Returns `true` if API prereqs (binary + key) are missing.
fn skip_if_no_api_prereqs() -> bool {
    if skip_if_no_prereqs() {
        return true;
    }
    if anthropic_api_key().is_none() {
        eprintln!(
            "Skipping: no Anthropic API key (set ANTHROPIC_API_KEY or RKAT_ANTHROPIC_API_KEY)"
        );
        return true;
    }
    false
}

/// Returns `true` if OpenAI API prereqs (binary + key) are missing.
fn skip_if_no_openai_api_prereqs() -> bool {
    if skip_if_no_prereqs() {
        return true;
    }
    if openai_api_key().is_none() {
        eprintln!("Skipping: no OpenAI API key (set OPENAI_API_KEY or RKAT_OPENAI_API_KEY)");
        return true;
    }
    false
}

/// Write a minimal config.toml into the `.rkat/` directory inside `project_dir`.
async fn write_smoke_config(
    project_dir: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
    let rkat_dir = project_dir.join(".rkat");
    tokio::fs::create_dir_all(&rkat_dir).await?;

    let mut config = Config::default();
    config.agent.max_tokens_per_turn = 256;
    config.agent.model = smoke_model();
    let config_toml = toml::to_string_pretty(&config)?;
    tokio::fs::write(rkat_dir.join("config.toml"), config_toml).await?;
    Ok(())
}

// ===========================================================================
// Scenario 26: CLI run + resume + verify persistence
// ===========================================================================

#[tokio::test]
#[ignore = "lane:e2e-live"]
async fn e2e_scenario_26_cli_run_resume_persistence() -> Result<(), Box<dyn std::error::Error>> {
    if skip_if_no_api_prereqs() {
        return Ok(());
    }

    // Outer test: spawn inner with isolated HOME / XDG dirs
    if std::env::var("RUN_TEST_E2E_SMOKE_11_INNER").is_ok() {
        return inner_e2e_cli_run_resume_persistence().await;
    }

    let temp_dir = TempDir::new()?;
    let project_dir = temp_dir.path().join("project");
    tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;

    let data_dir = temp_dir.path().join("data");
    tokio::fs::create_dir_all(&data_dir).await?;
    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    let status = Command::new(std::env::current_exe()?)
        .arg("e2e_scenario_26_cli_run_resume_persistence")
        .arg("--ignored")
        .env("RUN_TEST_E2E_SMOKE_11_INNER", "1")
        .env("CARGO_BIN_EXE_rkat", &rkat)
        .env("HOME", temp_dir.path())
        .env("XDG_DATA_HOME", &data_dir)
        .env("TEST_PROJECT_DIR", &project_dir)
        .env("TEST_DATA_DIR", &data_dir)
        .status()
        .await?;

    assert!(status.success(), "inner test failed");
    Ok(())
}

async fn inner_e2e_cli_run_resume_persistence() -> Result<(), Box<dyn std::error::Error>> {
    let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);
    let _data_dir = PathBuf::from(std::env::var("TEST_DATA_DIR")?);

    std::env::set_current_dir(&project_dir)?;
    write_smoke_config(&project_dir).await?;

    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    // --- Step 1: Initial run ---
    let output = timeout(
        Duration::from_secs(120),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args([
                "run",
                "My name is RkatBot. Remember my name.",
                "--tools",
                "safe",
                "--output",
                "json",
            ])
            .output(),
    )
    .await??;

    if !output.status.success() {
        return Err(format!(
            "rkat run failed (exit {:?}): {}",
            output.status.code(),
            String::from_utf8_lossy(&output.stderr)
        )
        .into());
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
        .map_err(|e| format!("Failed to parse JSON output: {e}\nstdout: {stdout}"))?;

    let session_id = parsed["session_id"]
        .as_str()
        .ok_or("session_id missing in initial run response")?
        .to_string();

    assert!(!session_id.is_empty(), "session_id should be non-empty");

    // --- Step 2: Resume and ask about name ---
    let output = timeout(
        Duration::from_secs(120),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args([
                "run",
                "--resume",
                &session_id,
                "What is my name? Reply with just the name.",
            ])
            .output(),
    )
    .await??;

    if !output.status.success() {
        return Err(format!(
            "rkat run --resume failed (exit {:?}): {}",
            output.status.code(),
            String::from_utf8_lossy(&output.stderr)
        )
        .into());
    }

    // Resume outputs plain text (no --output json on resume command)
    let resume_stdout = String::from_utf8_lossy(&output.stdout);
    let resume_text = resume_stdout.trim().to_lowercase();

    assert!(
        resume_text.contains("rkatbot"),
        "Resume response should mention 'RkatBot', got: {resume_stdout}"
    );

    Ok(())
}

// ===========================================================================
// Scenario 27: CLI shell + structured output
// ===========================================================================

#[tokio::test]
#[ignore = "lane:e2e-smoke"]
async fn e2e_scenario_27_cli_shell_and_structured_output() -> Result<(), Box<dyn std::error::Error>>
{
    if skip_if_no_api_prereqs() {
        return Ok(());
    }

    // Outer test
    if std::env::var("RUN_TEST_E2E_SMOKE_12_INNER").is_ok() {
        return inner_e2e_cli_shell_tool().await;
    }

    let temp_dir = TempDir::new()?;
    let project_dir = temp_dir.path().join("project");
    tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;

    let data_dir = temp_dir.path().join("data");
    tokio::fs::create_dir_all(&data_dir).await?;
    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    let status = Command::new(std::env::current_exe()?)
        .arg("e2e_scenario_27_cli_shell_and_structured_output")
        .arg("--ignored")
        .env("RUN_TEST_E2E_SMOKE_12_INNER", "1")
        .env("CARGO_BIN_EXE_rkat", &rkat)
        .env("HOME", temp_dir.path())
        .env("XDG_DATA_HOME", &data_dir)
        .env("TEST_PROJECT_DIR", &project_dir)
        .env("TEST_DATA_DIR", &data_dir)
        .status()
        .await?;

    assert!(status.success(), "inner test failed");
    Ok(())
}

async fn inner_e2e_cli_shell_tool() -> Result<(), Box<dyn std::error::Error>> {
    let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);

    std::env::set_current_dir(&project_dir)?;
    write_smoke_config(&project_dir).await?;

    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    let output = timeout(
        Duration::from_secs(120),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args([
                "run",
                "Use the shell to run 'echo SMOKE_OK_42' and tell me the output",
                "--tools",
                "workspace",
                "--output",
                "json",
            ])
            .output(),
    )
    .await??;

    assert!(
        output.status.success(),
        "rkat run with shell failed (exit {:?}): {}",
        output.status.code(),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
        .map_err(|e| format!("Failed to parse JSON output: {e}\nstdout: {stdout}"))?;

    let tool_calls = parsed["tool_calls"].as_u64().unwrap_or(0);
    assert!(
        tool_calls > 0,
        "Expected at least one tool call, got {tool_calls}"
    );

    let text = parsed["text"].as_str().unwrap_or("");
    assert!(
        text.contains("SMOKE_OK_42"),
        "Response text should contain 'SMOKE_OK_42', got: {text}"
    );

    let schema =
        r#"{"type":"object","properties":{"answer":{"type":"integer"}},"required":["answer"]}"#;
    let structured_output = timeout(
        Duration::from_secs(120),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args([
                "run",
                "What is 6 times 7? Give just the number.",
                "--schema",
                schema,
                "--output",
                "json",
            ])
            .output(),
    )
    .await??;

    assert!(
        structured_output.status.success(),
        "rkat structured output run failed (exit {:?}): {}",
        structured_output.status.code(),
        String::from_utf8_lossy(&structured_output.stderr)
    );

    let structured_stdout = String::from_utf8_lossy(&structured_output.stdout);
    let structured: serde_json::Value =
        serde_json::from_str(structured_stdout.trim()).map_err(|e| {
            format!("Failed to parse structured output JSON: {e}\nstdout: {structured_stdout}")
        })?;
    assert!(
        structured["structured_output"]["answer"].is_number(),
        "structured output should contain a numeric answer, got: {structured}"
    );

    Ok(())
}

// ===========================================================================
// Scenario 73: CLI generate_image blob save smoke
// ===========================================================================

#[tokio::test]
#[ignore = "lane:e2e-smoke"]
async fn e2e_scenario_73_cli_generate_image_openai_default()
-> Result<(), Box<dyn std::error::Error>> {
    if skip_if_no_openai_api_prereqs() {
        return Ok(());
    }

    if std::env::var("RUN_TEST_E2E_SMOKE_73_INNER").is_ok() {
        return inner_e2e_cli_generate_image_openai_default().await;
    }

    let temp_dir = TempDir::new()?;
    let project_dir = temp_dir.path().join("project");
    tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;

    let data_dir = temp_dir.path().join("data");
    tokio::fs::create_dir_all(&data_dir).await?;
    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    let status = Command::new(std::env::current_exe()?)
        .arg("e2e_scenario_73_cli_generate_image_openai_default")
        .arg("--ignored")
        .env("RUN_TEST_E2E_SMOKE_73_INNER", "1")
        .env("CARGO_BIN_EXE_rkat", &rkat)
        .env("HOME", temp_dir.path())
        .env("XDG_DATA_HOME", &data_dir)
        .env("TEST_PROJECT_DIR", &project_dir)
        .env("TEST_DATA_DIR", &data_dir)
        .status()
        .await?;

    assert!(status.success(), "inner test failed");
    Ok(())
}

async fn inner_e2e_cli_generate_image_openai_default() -> Result<(), Box<dyn std::error::Error>> {
    let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);

    std::env::set_current_dir(&project_dir)?;
    // Image generation needs more output tokens than the shared smoke config's
    // 256 — the model must emit web search calls, a generate_image tool call
    // with a detailed prompt, and a blob_save_file call.
    let rkat_dir = project_dir.join(".rkat");
    tokio::fs::create_dir_all(&rkat_dir).await?;
    let mut config = Config::default();
    config.agent.max_tokens_per_turn = 4096;
    config.agent.model = smoke_model();
    let config_toml = toml::to_string_pretty(&config)?;
    tokio::fs::write(rkat_dir.join("config.toml"), config_toml).await?;

    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
    let output = timeout(
        Duration::from_secs(600),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args([
                "run",
                "Check today's news and generate an infographic image with the top news. Save it to top-news-infographic.png",
                "--yolo",
                "-m",
                "gpt-5.5",
            ])
            .output(),
    )
    .await??;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let combined = format!("{stdout}\n{stderr}");
    assert!(
        output.status.success(),
        "rkat generate_image run failed (exit {:?}): {combined}",
        output.status.code()
    );
    for failure in [
        "unsupported_target",
        "guard rejected",
        "invalid_arguments",
        "execution_failed",
        "internal image operation state error",
        "currently unavailable",
        "don't currently have access",
        "isn't currently available",
        "couldn't create",
        "failed in this session",
        "\"terminal\":\"denied\"",
        "\"terminal\": \"denied\"",
    ] {
        assert!(
            !combined.to_ascii_lowercase().contains(failure),
            "CLI generate_image smoke hit failure marker {failure:?}: {combined}"
        );
    }
    let saved_path = project_dir.join("top-news-infographic.png");
    let saved = tokio::fs::read(&saved_path)
        .await
        .map_err(|err| format!("expected saved PNG at '{}': {err}", saved_path.display()))?;
    assert!(
        !saved.is_empty(),
        "saved infographic should be nonempty at '{}'",
        saved_path.display()
    );
    assert!(
        saved.len() >= 8,
        "saved infographic should be long enough to include a PNG signature"
    );
    assert_eq!(
        &saved[..8],
        &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
        "saved infographic should be a PNG file; output was: {combined}"
    );
    let temp_root = project_dir
        .parent()
        .ok_or("project dir should have a parent temp root")?;
    let events = read_jsonl_files_under(temp_root).await?;
    assert!(
        events.contains(r#""name":"generate_image""#),
        "smoke should exercise generate_image, not just create a PNG by other means; output was: {combined}"
    );
    assert!(
        events.contains(r#""name":"blob_save_file""#),
        "smoke should exercise blob_save_file to materialize the generated blob; output was: {combined}"
    );

    Ok(())
}

// ===========================================================================
// Scenario 28: CLI capabilities + config
// ===========================================================================

#[tokio::test]
#[ignore = "lane:e2e-system"]
async fn e2e_scenario_28_cli_capabilities_and_config() -> Result<(), Box<dyn std::error::Error>> {
    if skip_if_no_prereqs() {
        return Ok(());
    }

    // Outer test
    if std::env::var("RUN_TEST_E2E_SMOKE_13_INNER").is_ok() {
        return inner_e2e_cli_capabilities_and_config().await;
    }

    let temp_dir = TempDir::new()?;
    let project_dir = temp_dir.path().join("project");
    tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;

    let data_dir = temp_dir.path().join("data");
    tokio::fs::create_dir_all(&data_dir).await?;
    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    let status = Command::new(std::env::current_exe()?)
        .arg("e2e_scenario_28_cli_capabilities_and_config")
        .arg("--ignored")
        .env("RUN_TEST_E2E_SMOKE_13_INNER", "1")
        .env("CARGO_BIN_EXE_rkat", &rkat)
        .env("HOME", temp_dir.path())
        .env("XDG_DATA_HOME", &data_dir)
        .env("TEST_PROJECT_DIR", &project_dir)
        .env("TEST_DATA_DIR", &data_dir)
        .status()
        .await?;

    assert!(status.success(), "inner test failed");
    Ok(())
}

async fn inner_e2e_cli_capabilities_and_config() -> Result<(), Box<dyn std::error::Error>> {
    let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);

    std::env::set_current_dir(&project_dir)?;
    write_smoke_config(&project_dir).await?;

    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    // --- Step 1: capabilities ---
    let output = timeout(
        Duration::from_secs(30),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args(["capabilities"])
            .output(),
    )
    .await??;

    assert!(
        output.status.success(),
        "rkat capabilities failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    let caps: serde_json::Value = serde_json::from_str(stdout.trim())
        .map_err(|e| format!("Failed to parse capabilities JSON: {e}\nstdout: {stdout}"))?;

    // Verify contract_version is present
    assert!(
        caps.get("contract_version").is_some(),
        "capabilities response should have contract_version, got: {caps}"
    );
    let contract_version = &caps["contract_version"];
    assert!(
        contract_version.get("major").is_some(),
        "contract_version should have major field"
    );

    // Verify "sessions" capability is present
    let capabilities = caps["capabilities"]
        .as_array()
        .ok_or("capabilities should be an array")?;
    let has_sessions = capabilities
        .iter()
        .any(|c| c["id"].as_str() == Some("sessions"));
    assert!(
        has_sessions,
        "capabilities should include 'sessions', got: {:?}",
        capabilities
            .iter()
            .filter_map(|c| c["id"].as_str())
            .collect::<Vec<_>>()
    );

    // --- Step 2: config get ---
    let output = timeout(
        Duration::from_secs(30),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args(["config", "get"])
            .output(),
    )
    .await??;

    assert!(
        output.status.success(),
        "rkat config get failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let config_stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        !config_stdout.trim().is_empty(),
        "config get output should be non-empty"
    );

    // Default format is TOML; verify it looks like TOML (has section headers or key=value)
    assert!(
        config_stdout.contains('[') || config_stdout.contains('='),
        "config get output should look like TOML, got: {config_stdout}"
    );

    // --- Step 3: config get --format json --with-generation ---
    let output = timeout(
        Duration::from_secs(30),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args(["config", "get", "--format", "json", "--with-generation"])
            .output(),
    )
    .await??;

    assert!(
        output.status.success(),
        "rkat config get --with-generation failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let envelope_stdout = String::from_utf8_lossy(&output.stdout);
    let envelope: serde_json::Value =
        serde_json::from_str(envelope_stdout.trim()).map_err(|e| {
            format!("Failed to parse config envelope JSON: {e}\nstdout: {envelope_stdout}")
        })?;
    let baseline_generation = envelope["generation"]
        .as_u64()
        .ok_or("generation missing in config envelope")?;
    assert!(
        envelope.get("config").is_some(),
        "config envelope should include config field"
    );

    // --- Step 4: config patch with matching expected_generation succeeds ---
    let output = timeout(
        Duration::from_secs(30),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args([
                "config",
                "patch",
                "--json",
                r#"{"agent":{"max_tokens_per_turn":333}}"#,
                "--expected-generation",
                &baseline_generation.to_string(),
            ])
            .output(),
    )
    .await??;

    assert!(
        output.status.success(),
        "rkat config patch with expected-generation failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let patch_stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        patch_stdout.contains("generation="),
        "config patch output should contain generation marker, got: {patch_stdout}"
    );

    // --- Step 5: stale expected_generation fails deterministically ---
    let output = timeout(
        Duration::from_secs(30),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args([
                "config",
                "set",
                "--toml",
                "[agent]\nmodel = \"claude-opus-4-6\"\nmax_tokens_per_turn = 256\nbudget_warning_threshold = 0.8\n",
                "--expected-generation",
                &baseline_generation.to_string(),
            ])
            .output(),
    )
    .await??;

    assert!(
        !output.status.success(),
        "stale expected-generation write should fail"
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.to_lowercase().contains("generation") || stderr.to_lowercase().contains("expected"),
        "stale generation failure should mention generation mismatch, got: {stderr}"
    );

    Ok(())
}

// ===========================================================================
// Supplemental: CLI structured output only
// ===========================================================================

#[tokio::test]
#[ignore = "lane:e2e-live"]
async fn e2e_cli_structured_output() -> Result<(), Box<dyn std::error::Error>> {
    if skip_if_no_api_prereqs() {
        return Ok(());
    }

    // Outer test
    if std::env::var("RUN_TEST_E2E_SMOKE_14_INNER").is_ok() {
        return inner_e2e_cli_structured_output().await;
    }

    let temp_dir = TempDir::new()?;
    let project_dir = temp_dir.path().join("project");
    tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;

    let data_dir = temp_dir.path().join("data");
    tokio::fs::create_dir_all(&data_dir).await?;
    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    let status = Command::new(std::env::current_exe()?)
        .arg("e2e_cli_structured_output")
        .arg("--ignored")
        .env("RUN_TEST_E2E_SMOKE_14_INNER", "1")
        .env("CARGO_BIN_EXE_rkat", &rkat)
        .env("HOME", temp_dir.path())
        .env("XDG_DATA_HOME", &data_dir)
        .env("TEST_PROJECT_DIR", &project_dir)
        .env("TEST_DATA_DIR", &data_dir)
        .status()
        .await?;

    assert!(status.success(), "inner test failed");
    Ok(())
}

async fn inner_e2e_cli_structured_output() -> Result<(), Box<dyn std::error::Error>> {
    let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);

    std::env::set_current_dir(&project_dir)?;
    write_smoke_config(&project_dir).await?;

    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    let schema =
        r#"{"type":"object","properties":{"answer":{"type":"integer"}},"required":["answer"]}"#;

    let output = timeout(
        Duration::from_secs(120),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args([
                "run",
                "What is 6 times 7? Give just the number.",
                "--schema",
                schema,
                "--output",
                "json",
            ])
            .output(),
    )
    .await??;

    assert!(
        output.status.success(),
        "rkat run with structured output failed (exit {:?}): {}",
        output.status.code(),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
        .map_err(|e| format!("Failed to parse JSON output: {e}\nstdout: {stdout}"))?;

    // Verify structured_output is present and has the answer field
    let structured_output = &parsed["structured_output"];
    assert!(
        !structured_output.is_null(),
        "structured_output should be present in response, got: {parsed}"
    );

    let answer = structured_output.get("answer");
    assert!(
        answer.is_some(),
        "structured_output should have 'answer' field, got: {structured_output}"
    );

    // The answer should be a number (the LLM should return 42)
    let answer_val = answer.unwrap();
    assert!(
        answer_val.is_number(),
        "answer should be a number, got: {answer_val}"
    );

    Ok(())
}

// ===========================================================================
// E2E-001: Background job active-turn completion surfacing
// ===========================================================================

#[tokio::test]
#[ignore = "lane:e2e-smoke"]
async fn e2e_001_background_job_active_turn_completion() -> Result<(), Box<dyn std::error::Error>> {
    if skip_if_no_api_prereqs() {
        return Ok(());
    }

    if std::env::var("RUN_TEST_E2E_BG_001_INNER").is_ok() {
        return inner_e2e_001_bg_active_turn().await;
    }

    let temp_dir = TempDir::new()?;
    let project_dir = temp_dir.path().join("project");
    tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;

    let data_dir = temp_dir.path().join("data");
    tokio::fs::create_dir_all(&data_dir).await?;
    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    let status = Command::new(std::env::current_exe()?)
        .arg("e2e_001_background_job_active_turn_completion")
        .arg("--ignored")
        .env("RUN_TEST_E2E_BG_001_INNER", "1")
        .env("CARGO_BIN_EXE_rkat", &rkat)
        .env("HOME", temp_dir.path())
        .env("XDG_DATA_HOME", &data_dir)
        .env("TEST_PROJECT_DIR", &project_dir)
        .env("TEST_DATA_DIR", &data_dir)
        .status()
        .await?;

    assert!(status.success(), "inner test failed");
    Ok(())
}

async fn inner_e2e_001_bg_active_turn() -> Result<(), Box<dyn std::error::Error>> {
    let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);

    std::env::set_current_dir(&project_dir)?;
    write_smoke_config(&project_dir).await?;

    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    // Ask the agent to run a fast background job and wait for it to complete.
    // The agent should see the completion via [SYSTEM NOTICE][BG_JOB].
    let output = timeout(
        Duration::from_secs(120),
        Command::new(&rkat)
            .current_dir(&project_dir)
            .args([
                "run",
                "Run `echo bg_done` in the background using the shell tool with background=true. \
                 Then call shell_job_status to check on it. \
                 Report the final status including the job_id.",
                "--tools",
                "full",
                "--output",
                "json",
                "--stream",
            ])
            .output(),
    )
    .await??;

    assert!(
        output.status.success(),
        "rkat run failed (exit {:?}): {}",
        output.status.code(),
        String::from_utf8_lossy(&output.stderr),
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let combined = format!("{stdout}\n{stderr}");

    // The agent should surface a background job completion notice or mention
    // the job result. Look for evidence of the notification path.
    let has_bg_notice =
        combined.contains("[BG_JOB]") || combined.contains("background_job_completed");
    let has_job_id = combined.contains("job_id") || combined.contains("j_");
    let has_bg_done = combined.contains("bg_done");

    assert!(
        has_bg_notice || has_bg_done,
        "E2E-001: expected background job completion notice or output in transcript.\n\
         stdout: {stdout}\nstderr: {stderr}"
    );

    // CONTRACT-002: surfaced handle is job_id, not operation_id
    if has_bg_notice {
        assert!(
            has_job_id,
            "E2E-001: background notice should reference job_id (CONTRACT-002)"
        );
    }

    Ok(())
}

// ===========================================================================
// E2E-002: Background job idle keep-alive completion surfacing
// ===========================================================================

#[tokio::test]
#[ignore = "lane:e2e-smoke"]
async fn e2e_002_background_job_idle_keepalive_completion() -> Result<(), Box<dyn std::error::Error>>
{
    if skip_if_no_api_prereqs() {
        return Ok(());
    }

    if std::env::var("RUN_TEST_E2E_BG_002_INNER").is_ok() {
        return inner_e2e_002_bg_keepalive().await;
    }

    let temp_dir = TempDir::new()?;
    let project_dir = temp_dir.path().join("project");
    tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;

    let data_dir = temp_dir.path().join("data");
    tokio::fs::create_dir_all(&data_dir).await?;
    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    let status = Command::new(std::env::current_exe()?)
        .arg("e2e_002_background_job_idle_keepalive_completion")
        .arg("--ignored")
        .env("RUN_TEST_E2E_BG_002_INNER", "1")
        .env("CARGO_BIN_EXE_rkat", &rkat)
        .env("HOME", temp_dir.path())
        .env("XDG_DATA_HOME", &data_dir)
        .env("TEST_PROJECT_DIR", &project_dir)
        .env("TEST_DATA_DIR", &data_dir)
        .status()
        .await?;

    assert!(status.success(), "inner test failed");
    Ok(())
}

async fn inner_e2e_002_bg_keepalive() -> Result<(), Box<dyn std::error::Error>> {
    let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);

    std::env::set_current_dir(&project_dir)?;
    write_smoke_config(&project_dir).await?;

    let rkat = rkat_binary_path().ok_or("rkat binary not found")?;

    // Start a keep-alive session via --keep-alive. The agent stays alive after
    // its initial turn. We launch a background job, let the agent finish its
    // turn, then wait for the background job to complete while idle. The runtime
    // should wake and surface the completion without user input.
    //
    // We redirect output to a temp file and read it after killing the process,
    // because SIGKILL does not flush piped stdout/stderr.
    let stdout_file = project_dir.join("e2e_002_stdout.txt");
    let stderr_file = project_dir.join("e2e_002_stderr.txt");
    let stdout_f = std::fs::File::create(&stdout_file)?;
    let stderr_f = std::fs::File::create(&stderr_file)?;

    let mut child = Command::new(&rkat)
        .current_dir(&project_dir)
        .args([
            "run",
            "Run `sleep 2 && echo keepalive_bg_done` in the background using shell with background=true. \
             Then say 'Waiting for background job.' and stop.",
            "--tools",
            "full",
            "--keep-alive",
            "--stream",
        ])
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::from(stdout_f))
        .stderr(std::process::Stdio::from(stderr_f))
        .spawn()?;

    // Wait for the background job to complete (2s) plus margin for the
    // agent to process the initial turn + wake cycle.
    tokio::time::sleep(Duration::from_secs(20)).await;

    // Kill the process and read captured output from files
    child.kill().await.ok();
    let _ = child.wait().await;

    let stdout = tokio::fs::read_to_string(&stdout_file)
        .await
        .unwrap_or_default();
    let stderr = tokio::fs::read_to_string(&stderr_file)
        .await
        .unwrap_or_default();
    let combined = format!("{stdout}\n{stderr}");

    // The session should have woken up and surfaced the completion.
    let has_bg_notice =
        combined.contains("[BG_JOB]") || combined.contains("background_job_completed");
    let has_bg_done = combined.contains("keepalive_bg_done");

    assert!(
        has_bg_notice || has_bg_done,
        "E2E-002: expected background job completion surfaced during idle keep-alive.\n\
         The runtime should have woken the session without user input (REQ-002).\n\
         stdout: {stdout}\nstderr: {stderr}"
    );

    Ok(())
}
