//! Integration tests for the playbook system.
//!
//! These tests exercise the full playbook pipeline: parse → sandbox → execute → report.
//!
//! Two approaches are used:
//! - **Subprocess tests**: Invoke `bmux playbook run --json <fixture>` and assert on
//!   the JSON output. Tests the real CLI path end-to-end.
//! - **Direct API tests**: Import `bmux_cli::playbook::*` and call parsers/validators
//!   directly (lower-level checks).

use serial_test::serial;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;

/// Path to the built `bmux` binary.
fn bmux_binary() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_bmux"))
}

/// Path to the fixtures directory.
fn fixtures_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/playbooks")
}

const ATTACH_SIM_FIXTURES: &[&str] = &[
    "attach_sim_tab_drag.dsl",
    "attach_sim_tab_drag_no_motion.dsl",
    "attach_sim_tab_drag_left_half.dsl",
    "attach_sim_tab_drag_right_half.dsl",
    "attach_sim_tab_drag_gap.dsl",
    "attach_sim_tab_drag_mru_noop.dsl",
    "attach_sim_tab_click_switch.dsl",
    "attach_sim_status_top.dsl",
    "attach_sim_status_snapshot.dsl",
    "attach_sim_scrollback_selection.dsl",
    "attach_sim_pane_resize.dsl",
    "attach_sim_floating_pane_drag.dsl",
    "attach_sim_prompt_overlay.dsl",
    "attach_sim_help_overlay_scroll.dsl",
];

struct TempDirGuard {
    path: PathBuf,
}

impl TempDirGuard {
    fn new(label: &str) -> Self {
        let path = std::env::temp_dir().join(format!(
            "bmux-playbook-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 BmuxCommandSandbox {
    root: TempDirGuard,
}

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

    fn command(&self) -> Command {
        let mut command = Command::new(bmux_binary());
        self.apply(&mut command);
        command
    }

    fn apply(&self, command: &mut Command) {
        command
            .env("BMUX_CONFIG_DIR", self.root.path().join("config"))
            .env("BMUX_RUNTIME_DIR", self.root.path().join("runtime"))
            .env("BMUX_DATA_DIR", self.root.path().join("data"))
            .env("BMUX_STATE_DIR", self.root.path().join("state"))
            .env("BMUX_LOG_DIR", self.root.path().join("logs"));
    }
}

/// Construct a platform-appropriate IPC endpoint from a socket path string.
fn endpoint_from_socket_path(path: &str) -> bmux_ipc::IpcEndpoint {
    if cfg!(windows) {
        bmux_ipc::IpcEndpoint::windows_named_pipe(path.to_string())
    } else {
        bmux_ipc::IpcEndpoint::unix_socket(path)
    }
}

async fn send_json_line(
    writer: &mut (impl tokio::io::AsyncWrite + Unpin),
    payload: &serde_json::Value,
) {
    use tokio::io::AsyncWriteExt;

    writer
        .write_all(format!("{}\n", payload).as_bytes())
        .await
        .expect("failed to write json command");
    writer.flush().await.expect("failed to flush");
}

async fn read_json_line(reader: &mut (impl tokio::io::AsyncBufRead + Unpin)) -> serde_json::Value {
    use tokio::io::AsyncBufReadExt;

    let mut line = String::new();
    let read = reader
        .read_line(&mut line)
        .await
        .expect("failed to read response line");
    assert!(read > 0, "unexpected EOF while waiting for response line");
    serde_json::from_str(line.trim())
        .unwrap_or_else(|e| panic!("failed to parse json line: {e}\nline: {line}"))
}

async fn read_until_json(
    reader: &mut (impl tokio::io::AsyncBufRead + Unpin),
    max_messages: usize,
    context: &str,
    mut predicate: impl FnMut(&serde_json::Value) -> bool,
) -> serde_json::Value {
    let mut last_message = None;
    for _ in 0..max_messages {
        let msg = read_json_line(reader).await;
        if predicate(&msg) {
            return msg;
        }
        last_message = Some(msg);
    }
    panic!(
        "did not observe expected message for {context} within {max_messages} messages; last={:#}",
        last_message.unwrap_or(serde_json::json!({"status":"none"}))
    );
}

async fn read_response_for_request_id(
    reader: &mut (impl tokio::io::AsyncBufRead + Unpin),
    request_id: &str,
) -> serde_json::Value {
    read_until_json(reader, 80, request_id, |msg| {
        msg["request_id"] == request_id && (msg["type"] == "response" || msg["type"] == "error")
    })
    .await
}

/// Run a playbook fixture via the bmux binary and return parsed JSON output.
fn run_playbook_fixture(name: &str) -> (serde_json::Value, bool) {
    let fixture = fixtures_dir().join(name);
    assert!(fixture.exists(), "fixture not found: {}", fixture.display());

    let sandbox = BmuxCommandSandbox::new("run-playbook-fixture");
    let output = sandbox
        .command()
        .args(["playbook", "run", "--json", fixture.to_str().unwrap()])
        .env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
        .output()
        .expect("failed to run bmux playbook");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
        panic!(
            "failed to parse JSON output for {name}:\n  error: {e}\n  stdout: {stdout}\n  stderr: {stderr}\n  exit: {:?}",
            output.status
        )
    });

    let pass = json["pass"].as_bool().unwrap_or(false);
    (json, pass)
}

#[test]
#[serial]
fn playbook_run_interactive_step_controls() {
    use std::io::Write;

    let fixture = fixtures_dir().join("echo_hello.dsl");
    assert!(fixture.exists(), "fixture not found: {}", fixture.display());

    let sandbox = BmuxCommandSandbox::new("interactive-step-controls");
    let mut child = sandbox
        .command()
        .args([
            "playbook",
            "run",
            "--json",
            "--interactive",
            fixture.to_str().expect("fixture path should be utf-8"),
        ])
        .env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn interactive playbook run");

    {
        let mut stdin = child.stdin.take().expect("stdin should be piped");
        stdin
            .write_all(
                b"n\n: send-keys keys='echo adhoc_interactive_marker\\r'\n: wait-for pattern='adhoc_interactive_marker'\ns\nc\n",
            )
            .expect("failed writing interactive commands");
    }

    let output = child
        .wait_with_output()
        .expect("failed waiting for interactive run");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
        panic!(
            "failed to parse JSON output:\n  error: {e}\n  stdout: {stdout}\n  stderr: {stderr}\n  exit: {:?}",
            output.status
        )
    });

    assert!(
        json["pass"].as_bool().unwrap_or(false),
        "interactive run should pass: {json:#}"
    );
    let steps = json["steps"].as_array().expect("steps should be array");
    assert!(
        steps.iter().all(|step| step["status"] == "pass"),
        "all scheduled steps should pass: {json:#}"
    );

    assert!(
        stderr.contains("interactive playbook controls"),
        "stderr should include controls help: {stderr}"
    );
    assert!(
        stderr.contains("adhoc_interactive_marker"),
        "screen output should include ad-hoc marker: {stderr}"
    );
}

// ---------------------------------------------------------------------------
// Subprocess integration tests
// ---------------------------------------------------------------------------

#[test]
fn playbook_echo_hello() {
    let (json, pass) = run_playbook_fixture("echo_hello.dsl");
    assert!(pass, "playbook should pass: {json:#}");

    let steps = json["steps"].as_array().expect("steps should be array");
    assert!(
        steps.iter().all(|s| s["status"] == "pass"),
        "all steps should pass: {json:#}"
    );
}

#[test]
fn playbook_echo_assert() {
    let (json, pass) = run_playbook_fixture("echo_assert.dsl");
    assert!(pass, "playbook should pass: {json:#}");

    let steps = json["steps"].as_array().expect("steps should be array");
    // Find the assert-screen steps and verify they all pass.
    let assert_steps: Vec<_> = steps
        .iter()
        .filter(|s| s["action"] == "assert-screen")
        .collect();
    assert!(
        assert_steps.len() >= 2,
        "should have at least 2 assert-screen steps: {json:#}"
    );
    for step in &assert_steps {
        assert_eq!(
            step["status"], "pass",
            "assert-screen should pass: {step:#}"
        );
    }
}

#[test]
fn playbook_multi_pane() {
    let (json, pass) = run_playbook_fixture("multi_pane.dsl");
    assert!(pass, "multi-pane playbook should pass: {json:#}");

    let steps = json["steps"].as_array().expect("steps should be array");
    // Check that pane-targeted assert-screen steps pass.
    let assert_steps: Vec<_> = steps
        .iter()
        .filter(|s| s["action"] == "assert-screen")
        .collect();
    assert!(
        assert_steps.len() >= 2,
        "should have assert-screen steps for both panes: {json:#}"
    );
    for step in &assert_steps {
        assert_eq!(step["status"], "pass", "assert should pass: {step:#}");
    }
}

#[test]
fn playbook_wait_for_regex() {
    let (json, pass) = run_playbook_fixture("wait_for_regex.dsl");
    assert!(pass, "wait-for regex should pass: {json:#}");
}

#[test]
fn playbook_attach_scrollback() {
    let (json, pass) = run_playbook_fixture("attach_scrollback.dsl");
    assert!(
        pass,
        "attach scrollback playbook should pass without leaking literal keys: {json:#}"
    );
}

#[test]
fn playbook_alt_screen_exit_cursor() {
    let (json, pass) = run_playbook_fixture("alt_screen_exit_cursor.dsl");
    assert!(
        pass,
        "alt-screen enter/exit cursor restoration playbook should pass: {json:#}"
    );
}

#[test]
fn playbook_synthetic_alt_sigint_reentry_restores_cursor() {
    let (json, pass) = run_playbook_fixture("synthetic_alt_sigint_reentry.dsl");
    assert!(
        pass,
        "synthetic SIGINT alt-screen reentry should restore cursor to pre-alt anchor: {json:#}"
    );

    let steps = json["steps"].as_array().expect("steps should be array");
    let cursor_step = steps
        .iter()
        .find(|step| step["action"] == "assert-cursor")
        .expect("expected assert-cursor step in synthetic fixture");
    assert_eq!(
        cursor_step["status"], "pass",
        "cursor assertion should pass once reentry is correct: {json:#}"
    );
}

#[test]
fn playbook_synthetic_alt_split_exit_reentry_restores_cursor() {
    let (json, pass) = run_playbook_fixture("synthetic_alt_split_exit_reentry.dsl");
    assert!(
        pass,
        "synthetic split 1049 exit sequence should restore cursor to pre-alt anchor: {json:#}"
    );

    let steps = json["steps"].as_array().expect("steps should be array");
    let cursor_step = steps
        .iter()
        .find(|step| step["action"] == "assert-cursor")
        .expect("expected assert-cursor step in synthetic fixture");
    assert_eq!(
        cursor_step["status"], "pass",
        "cursor assertion should pass once split-sequence parsing is correct: {json:#}"
    );
}

#[test]
fn playbook_send_keys_fails_during_attach_scrollback() {
    let (json, pass) = run_playbook_fixture("send_keys_in_scrollback.dsl");
    assert!(
        !pass,
        "send-keys should fail when attach scrollback is active: {json:#}"
    );
    let error = json["error"].as_str().unwrap_or("");
    assert!(
        error.contains("send-keys targets pane input while attach scrollback is active"),
        "error should explain send-keys/send-attach split: {json:#}"
    );
}

#[test]
fn playbook_env_mode_clean() {
    let fixture = fixtures_dir().join("env_mode_clean.dsl");

    // For this test, do NOT set BMUX_PLAYBOOK_ENV_MODE — the playbook itself
    // has @env-mode clean, so the sandbox should use clean mode.
    let sandbox = BmuxCommandSandbox::new("env-mode-clean");
    let output = sandbox
        .command()
        .args(["playbook", "run", "--json", fixture.to_str().unwrap()])
        .output()
        .expect("failed to run bmux playbook");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let json: serde_json::Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}\nstderr: {stderr}"));

    let pass = json["pass"].as_bool().unwrap_or(false);
    assert!(
        pass,
        "env_mode_clean playbook should pass (TERM should be xterm-256color): {json:#}"
    );
}

#[test]
fn playbook_screen_status() {
    let (json, pass) = run_playbook_fixture("screen_status.dsl");
    assert!(pass, "screen/status playbook should pass: {json:#}");

    let steps = json["steps"].as_array().expect("steps should be array");

    // Find the screen step and verify it has detail with pane data.
    let screen_step = steps.iter().find(|s| s["action"] == "screen");
    assert!(screen_step.is_some(), "should have a screen step: {json:#}");
    let detail = screen_step.unwrap()["detail"].as_str().unwrap_or("");
    assert!(
        !detail.is_empty(),
        "screen step should have detail: {json:#}"
    );

    // Find the status step.
    let status_step = steps.iter().find(|s| s["action"] == "status");
    assert!(status_step.is_some(), "should have a status step: {json:#}");
    let status_detail = status_step.unwrap()["detail"].as_str().unwrap_or("");
    assert!(
        status_detail.contains("session_id="),
        "status should contain session_id: {status_detail}"
    );
}

#[test]
fn playbook_snapshot_capture() {
    let (json, pass) = run_playbook_fixture("snapshot_capture.dsl");
    assert!(pass, "snapshot playbook should pass: {json:#}");

    let snapshots = json["snapshots"].as_array();
    assert!(
        snapshots.is_some() && !snapshots.unwrap().is_empty(),
        "should have snapshots: {json:#}"
    );
    let snap = &snapshots.unwrap()[0];
    assert_eq!(snap["id"], "after_echo", "snapshot id: {snap:#}");
    let panes = snap["panes"]
        .as_array()
        .expect("snapshot should have panes");
    assert!(!panes.is_empty(), "snapshot should have pane captures");
    let pane_text = panes[0]["screen_text"].as_str().unwrap_or("");
    assert!(
        pane_text.contains("snap_content_marker"),
        "snapshot pane text should contain marker: {pane_text}"
    );
}

#[test]
fn playbook_failing_assert() {
    let (json, pass) = run_playbook_fixture("failing_assert.dsl");
    assert!(!pass, "failing playbook should not pass: {json:#}");

    let steps = json["steps"].as_array().expect("steps should be array");
    // Find the assert-screen step that should have failed.
    let failed_step = steps
        .iter()
        .find(|s| s["action"] == "assert-screen" && s["status"] == "fail");
    assert!(
        failed_step.is_some(),
        "should have a failed assert-screen step: {json:#}"
    );
    let failed = failed_step.unwrap();

    // Verify structured failure fields are present.
    assert!(
        failed.get("expected").is_some() && !failed["expected"].is_null(),
        "failed step should have 'expected' field: {failed:#}"
    );
    assert_eq!(
        failed["expected"].as_str().unwrap(),
        "nonexistent_string_xyz",
        "expected should be the contains pattern"
    );
    assert!(
        failed.get("actual").is_some() && !failed["actual"].is_null(),
        "failed step should have 'actual' field: {failed:#}"
    );
    // The actual screen text should contain the real output from the playbook.
    assert!(
        failed["actual"].as_str().unwrap().contains("real_output"),
        "actual should contain the real screen text: {failed:#}"
    );

    // Verify failure_captures is present with at least one pane.
    let captures = failed["failure_captures"].as_array();
    assert!(
        captures.is_some() && !captures.unwrap().is_empty(),
        "failed step should have failure_captures: {failed:#}"
    );
    let pane = &captures.unwrap()[0];
    assert!(
        pane["screen_text"]
            .as_str()
            .unwrap_or("")
            .contains("real_output"),
        "failure_captures pane should contain real_output: {pane:#}"
    );

    // Verify sandbox_root is included for failed playbooks.
    assert!(
        json.get("sandbox_root").is_some() && !json["sandbox_root"].is_null(),
        "failed playbook should have sandbox_root: {json:#}"
    );
    let root = json["sandbox_root"].as_str().unwrap();
    assert!(
        root.contains("bpb-"),
        "sandbox_root should be a bpb temp dir: {root}"
    );
}

// ---------------------------------------------------------------------------
// Direct API tests (via lib.rs)
// ---------------------------------------------------------------------------

#[test]
fn parse_and_validate_fixtures() {
    let fixtures = [
        "alt_screen_exit_cursor.dsl",
        "synthetic_alt_sigint_reentry.dsl",
        "synthetic_alt_split_exit_reentry.dsl",
        "echo_hello.dsl",
        "echo_assert.dsl",
        "multi_pane.dsl",
        "wait_for_regex.dsl",
        "attach_scrollback.dsl",
        "send_keys_in_scrollback.dsl",
        "env_mode_clean.dsl",
        "screen_status.dsl",
        "snapshot_capture.dsl",
        "failing_assert.dsl",
        "assert_matches.dsl",
        "include_main.dsl",
        "timeout_wait_for.dsl",
        "timeout_playbook.dsl",
        "var_override.dsl",
        "continue_on_error.dsl",
        "shell_exit.dsl",
        "assert_matches_fail.dsl",
        "render_assert_idle.dsl",
        "render_assert_single_line_output.dsl",
        "render_assert_exact_trace_single_line.dsl",
        "render_assert_status_only.dsl",
        "render_assert_focus_change.dsl",
        "render_assert_split_pane.dsl",
        "render_assert_resize_viewport.dsl",
        "render_assert_alt_screen_transition.dsl",
        "structured_reflow_basic.dsl",
        "structured_reflow_layout.dsl",
        "structured_reflow_scrollback.dsl",
    ];

    for name in fixtures.iter().chain(ATTACH_SIM_FIXTURES.iter()) {
        let path = fixtures_dir().join(name);
        let playbook = bmux_cli::playbook::parse_file(&path)
            .unwrap_or_else(|e| panic!("failed to parse {name}: {e:#}"));

        let errors = bmux_cli::playbook::validate(&playbook, false);
        assert!(
            errors.is_empty(),
            "validation errors for {name}: {errors:?}"
        );
    }
}

#[test]
fn attach_sim_fixtures_run_without_sandbox() {
    for name in ATTACH_SIM_FIXTURES {
        let (json, pass) = run_playbook_fixture(name);
        assert!(pass, "attach-sim fixture failed: {name}");
        if *name == "attach_sim_status_snapshot.dsl" {
            assert_eq!(json["snapshots"][0]["id"], "initial-status");
            assert!(
                json["snapshots"][0]["panes"][0]["screen_text"]
                    .as_str()
                    .unwrap_or_default()
                    .contains("1:one")
            );
        }
    }
}

// ---------------------------------------------------------------------------
// Subprocess: render assertions
// ---------------------------------------------------------------------------

#[test]
fn playbook_render_assert_idle() {
    assert_render_fixture_passes("render_assert_idle.dsl");
}

#[test]
fn playbook_render_assert_single_line_output() {
    assert_render_fixture_passes("render_assert_single_line_output.dsl");
}

#[test]
fn playbook_render_assert_exact_trace_single_line() {
    assert_render_fixture_passes("render_assert_exact_trace_single_line.dsl");
}

#[test]
fn playbook_render_assert_status_only() {
    assert_render_fixture_passes("render_assert_status_only.dsl");
}

#[test]
fn playbook_render_assert_focus_change() {
    assert_render_fixture_passes("render_assert_focus_change.dsl");
}

#[test]
fn playbook_render_assert_split_pane() {
    assert_render_fixture_passes("render_assert_split_pane.dsl");
}

#[test]
fn playbook_render_assert_resize_viewport() {
    assert_render_fixture_passes("render_assert_resize_viewport.dsl");
}

#[test]
fn playbook_render_assert_alt_screen_transition() {
    assert_render_fixture_passes("render_assert_alt_screen_transition.dsl");
}

#[test]
#[serial]
fn playbook_structured_reflow_basic() {
    let (json, pass) = run_playbook_fixture("structured_reflow_basic.dsl");
    assert!(pass, "structured reflow playbook should pass: {json:#}");
}

#[test]
#[serial]
fn playbook_structured_reflow_layout_changes() {
    let (json, pass) = run_playbook_fixture("structured_reflow_layout.dsl");
    assert!(
        pass,
        "structured reflow layout playbook should pass: {json:#}"
    );
}

#[test]
#[serial]
fn playbook_structured_reflow_scrollback() {
    let (json, pass) = run_playbook_fixture("structured_reflow_scrollback.dsl");
    assert!(
        pass,
        "structured reflow scrollback playbook should pass: {json:#}"
    );
}

fn assert_render_fixture_passes(fixture: &str) {
    let (json, pass) = run_playbook_fixture(fixture);
    assert!(pass, "render assertion should pass: {json:#}");
    let steps = json["steps"].as_array().unwrap();
    let assert_step = steps
        .iter()
        .find(|step| step["action"] == "assert-render")
        .expect("assert-render step should be present");
    assert_eq!(assert_step["status"], "pass");
    assert!(
        assert_step["detail"]
            .as_str()
            .is_some_and(|detail| detail.contains("render assertion passed")),
        "assert-render detail should summarize pass: {assert_step:#}"
    );
}

// ---------------------------------------------------------------------------
// Subprocess: assert-screen matches= (regex)
// ---------------------------------------------------------------------------

#[test]
fn playbook_assert_matches() {
    let (json, pass) = run_playbook_fixture("assert_matches.dsl");
    assert!(pass, "assert-screen matches= should pass: {json:#}");
    let steps = json["steps"].as_array().unwrap();
    let assert_step = steps
        .iter()
        .find(|s| s["action"] == "assert-screen")
        .expect("should have an assert-screen step");
    assert_eq!(
        assert_step["status"], "pass",
        "assert-screen matches= should pass"
    );
}

// ---------------------------------------------------------------------------
// Subprocess: dry-run
// ---------------------------------------------------------------------------

#[test]
fn playbook_dry_run() {
    let fixture = fixtures_dir().join("echo_hello.dsl");

    let sandbox = BmuxCommandSandbox::new("dry-run");
    let output = sandbox
        .command()
        .args(["playbook", "dry-run", "--json", fixture.to_str().unwrap()])
        .output()
        .expect("failed to run bmux playbook dry-run");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let json: serde_json::Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}\nstderr: {stderr}"));

    assert_eq!(json["valid"], true, "dry-run should be valid: {json:#}");

    let steps = json["steps"].as_array().expect("should have steps array");
    assert!(!steps.is_empty(), "dry-run should list steps: {json:#}");

    // Verify each step has index, action, and dsl fields.
    for step in steps {
        assert!(
            step.get("index").is_some(),
            "step should have index: {step:#}"
        );
        assert!(
            step.get("action").is_some(),
            "step should have action: {step:#}"
        );
        assert!(step.get("dsl").is_some(), "step should have dsl: {step:#}");
    }

    // Verify the dsl field contains recognizable DSL.
    let first_dsl = steps[0]["dsl"].as_str().unwrap_or("");
    assert!(
        first_dsl.starts_with("new-session"),
        "first step dsl should be new-session: {first_dsl}"
    );

    // Verify config section.
    assert!(
        json.get("config").is_some(),
        "dry-run should have config: {json:#}"
    );

    // Check errors is empty.
    let errors = json["errors"].as_array().expect("should have errors array");
    assert!(errors.is_empty(), "should have no errors: {json:#}");

    // Exit code should be 0.
    assert!(output.status.success(), "dry-run exit code should be 0");
}

// ---------------------------------------------------------------------------
// Direct API: run_playbook (async)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn run_playbook_echo_pass() {
    let dsl = "\
@viewport cols=80 rows=24
@shell sh
new-session
send-keys keys='echo api_test_marker\\r'
wait-for pattern='api_test_marker'
assert-screen contains='api_test_marker'
";
    let (mut playbook, _) = bmux_cli::playbook::parse_dsl::parse_dsl(dsl).unwrap();
    playbook.config.binary = Some(PathBuf::from(env!("CARGO_BIN_EXE_bmux")));

    let result = bmux_cli::playbook::run(playbook, false).await.unwrap();
    assert!(result.pass, "playbook should pass: {:?}", result.error);
    assert!(
        result
            .steps
            .iter()
            .all(|s| s.status == bmux_cli::playbook::types::StepStatus::Pass),
        "all steps should pass: {:?}",
        result.steps
    );
}

#[tokio::test]
async fn run_playbook_failing_returns_fail() {
    let dsl = "\
@viewport cols=80 rows=24
@shell sh
new-session
send-keys keys='echo real_output\\r'
wait-for pattern='real_output'
assert-screen contains='nonexistent_xyz_api'
";
    let (mut playbook, _) = bmux_cli::playbook::parse_dsl::parse_dsl(dsl).unwrap();
    playbook.config.binary = Some(PathBuf::from(env!("CARGO_BIN_EXE_bmux")));

    let result = bmux_cli::playbook::run(playbook, false).await.unwrap();
    assert!(!result.pass, "playbook should fail");

    let failed = result
        .steps
        .iter()
        .find(|s| s.status == bmux_cli::playbook::types::StepStatus::Fail);
    assert!(failed.is_some(), "should have a failed step");

    let f = failed.unwrap();
    assert!(f.expected.is_some(), "failed step should have expected");
    assert!(f.actual.is_some(), "failed step should have actual");
    assert!(
        f.failure_captures.is_some(),
        "failed step should have failure_captures"
    );
}

// ---------------------------------------------------------------------------
// Direct API: end-to-end recording → playbook → execution
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// G: Include system tests
// ---------------------------------------------------------------------------

#[test]
fn playbook_include_basic() {
    let (json, pass) = run_playbook_fixture("include_main.dsl");
    assert!(pass, "include playbook should pass: {json:#}");

    let steps = json["steps"].as_array().expect("steps should be array");
    // The included file has new-session + send-keys + wait-for (3 steps),
    // then the main file adds assert-screen (1 step) = 4 total.
    assert!(
        steps.len() >= 4,
        "should have steps from both files: {json:#}"
    );
    // The first step should be new-session (from included file).
    assert_eq!(
        steps[0]["action"], "new-session",
        "first step should be new-session from include"
    );
    // The last step should be assert-screen (from main file).
    let last = steps.last().unwrap();
    assert_eq!(
        last["action"], "assert-screen",
        "last step should be assert-screen from main file: {json:#}"
    );
}

#[test]
fn playbook_include_validates() {
    let path = fixtures_dir().join("include_main.dsl");
    let playbook = bmux_cli::playbook::parse_file(&path)
        .unwrap_or_else(|e| panic!("failed to parse include_main.dsl: {e:#}"));

    let errors = bmux_cli::playbook::validate(&playbook, false);
    assert!(
        errors.is_empty(),
        "include playbook should validate: {errors:?}"
    );

    // Verify we have steps from both the main and included files.
    assert!(
        playbook.steps.len() >= 4,
        "should have merged steps from include: {} steps",
        playbook.steps.len()
    );
}

// ---------------------------------------------------------------------------
// H: Timeout behavior tests
// ---------------------------------------------------------------------------

#[test]
fn playbook_wait_for_timeout() {
    let (json, pass) = run_playbook_fixture("timeout_wait_for.dsl");
    assert!(!pass, "wait-for timeout playbook should fail: {json:#}");

    let steps = json["steps"].as_array().expect("steps should be array");
    let wait_step = steps
        .iter()
        .find(|s| s["action"] == "wait-for" && s["status"] == "fail");
    assert!(
        wait_step.is_some(),
        "should have a failed wait-for step: {json:#}"
    );

    let ws = wait_step.unwrap();
    let detail = ws["detail"].as_str().unwrap_or("");
    assert!(
        detail.contains("timed out"),
        "detail should mention timeout: {detail}"
    );
    // Verify structured failure fields.
    assert!(
        ws.get("expected").is_some() && !ws["expected"].is_null(),
        "wait-for timeout should have expected field: {ws:#}"
    );
    assert!(
        ws.get("actual").is_some() && !ws["actual"].is_null(),
        "wait-for timeout should have actual field: {ws:#}"
    );
}

#[test]
fn playbook_level_timeout_skips_remaining() {
    let (json, pass) = run_playbook_fixture("timeout_playbook.dsl");
    assert!(!pass, "playbook timeout should fail: {json:#}");

    let steps = json["steps"].as_array().expect("steps should be array");
    // Find a step with status "skip" and detail containing "playbook timeout".
    let skipped = steps.iter().find(|s| s["status"] == "skip");
    assert!(
        skipped.is_some(),
        "should have a skipped step from playbook timeout: {json:#}"
    );
    let detail = skipped.unwrap()["detail"].as_str().unwrap_or("");
    assert!(
        detail.contains("playbook timeout"),
        "skipped step should mention playbook timeout: {detail}"
    );

    // Verify either the top-level error mentions timeout, or a step was skipped.
    let error = json["error"].as_str().unwrap_or("");
    let has_skip = steps.iter().any(|s| s["status"] == "skip");
    assert!(
        error.contains("exceeded after") || error.contains("timeout") || has_skip,
        "should show timeout info: error={error}, steps={json:#}"
    );
}

// ---------------------------------------------------------------------------
// I: from-recording CLI integration test
// ---------------------------------------------------------------------------

#[test]
fn playbook_from_recording_cli() {
    // Step 1: Run a playbook with --record to produce a recording.
    let fixture = fixtures_dir().join("echo_hello.dsl");
    let sandbox = BmuxCommandSandbox::new("from-recording");
    let output = sandbox
        .command()
        .args([
            "playbook",
            "run",
            "--json",
            "--record",
            fixture.to_str().unwrap(),
        ])
        .env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
        .output()
        .expect("failed to run bmux playbook with recording");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let json: serde_json::Value = match serde_json::from_str(&stdout) {
        Ok(j) => j,
        Err(e) => {
            eprintln!("JSON parse failed (recording run): {e}\nstdout: {stdout}");
            return; // Skip if we can't parse -- recording might not be supported in this env
        }
    };

    let recording_id = match json["recording_id"].as_str() {
        Some(id) => id.to_string(),
        None => {
            eprintln!("No recording_id in output, skipping from-recording test");
            return; // Recording wasn't produced -- skip gracefully
        }
    };

    // Step 2: Run from-recording to generate DSL from the recording.
    let from_output = sandbox
        .command()
        .args(["playbook", "from-recording", &recording_id])
        .output()
        .expect("failed to run bmux playbook from-recording");

    let dsl = String::from_utf8_lossy(&from_output.stdout);
    assert!(
        from_output.status.success(),
        "from-recording should succeed. stderr: {}",
        String::from_utf8_lossy(&from_output.stderr)
    );

    // Step 3: Verify the generated DSL contains expected elements.
    assert!(
        dsl.contains("new-session"),
        "generated DSL should contain new-session: {dsl}"
    );
    assert!(
        dsl.contains("send-keys"),
        "generated DSL should contain send-keys: {dsl}"
    );

    // Step 4: Validate the generated DSL parses cleanly.
    let parse_result = bmux_cli::playbook::parse_dsl::parse_dsl(&dsl);
    assert!(
        parse_result.is_ok(),
        "generated DSL should parse: {:?}",
        parse_result.err()
    );
}

// ---------------------------------------------------------------------------
// C: assert-screen matches= failure tests
// ---------------------------------------------------------------------------

#[test]
fn playbook_assert_matches_fail() {
    let (json, pass) = run_playbook_fixture("assert_matches_fail.dsl");
    assert!(
        !pass,
        "matches= on non-matching regex should fail: {json:#}"
    );

    let steps = json["steps"].as_array().expect("steps should be array");
    let failed = steps
        .iter()
        .find(|s| s["action"] == "assert-screen" && s["status"] == "fail");
    assert!(
        failed.is_some(),
        "should have a failed assert-screen step: {json:#}"
    );

    let f = failed.unwrap();
    assert!(
        f.get("expected").is_some() && !f["expected"].is_null(),
        "should have expected field: {f:#}"
    );
    assert!(
        f.get("actual").is_some() && !f["actual"].is_null(),
        "should have actual field (screen text): {f:#}"
    );
    assert!(
        f.get("failure_captures").is_some() && !f["failure_captures"].is_null(),
        "should have failure_captures: {f:#}"
    );
    // The actual screen text should contain the real output.
    assert!(
        f["actual"].as_str().unwrap_or("").contains("real_output"),
        "actual should contain 'real_output': {f:#}"
    );
}

#[test]
fn playbook_assert_matches_invalid_regex() {
    let (json, pass) = run_playbook_fixture("assert_matches_invalid_regex.dsl");
    assert!(!pass, "invalid regex should fail: {json:#}");

    let steps = json["steps"].as_array().expect("steps should be array");
    let failed = steps
        .iter()
        .find(|s| s["action"] == "assert-screen" && s["status"] == "fail");
    assert!(
        failed.is_some(),
        "should have a failed assert-screen step: {json:#}"
    );
    let detail = failed.unwrap()["detail"].as_str().unwrap_or("");
    assert!(
        detail.to_lowercase().contains("regex"),
        "detail should mention regex error: {detail}"
    );
}

// ---------------------------------------------------------------------------
// A: Interactive mode integration test
// ---------------------------------------------------------------------------

/// Guard that kills a child process on drop (for test cleanup).
struct ProcessGuard {
    pid: u32,
}

impl ProcessGuard {
    fn new(child: &std::process::Child) -> Self {
        Self { pid: child.id() }
    }
}

impl Drop for ProcessGuard {
    fn drop(&mut self) {
        // Best-effort kill on test failure/panic.
        let _ = std::process::Command::new("kill")
            .args(["-9", &self.pid.to_string()])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status();
    }
}

#[tokio::test]
#[serial]
async fn interactive_mode_basic() {
    use std::process::Stdio;
    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader};

    // Step 1: Spawn the interactive session.
    let sandbox = BmuxCommandSandbox::new("interactive-mode-basic");
    let mut child = sandbox
        .command()
        .args([
            "playbook",
            "interactive",
            "--viewport",
            "80x24",
            "--shell",
            "sh",
            "--timeout",
            "120",
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn bmux playbook interactive");

    let _guard = ProcessGuard::new(&child);

    // Step 2: Read the ready message from stdout.
    let stdout = child.stdout.take().expect("stdout should be piped");
    let mut stdout_reader = std::io::BufReader::new(stdout);
    let mut ready_line = String::new();
    std::io::BufRead::read_line(&mut stdout_reader, &mut ready_line)
        .expect("failed to read ready message");

    let ready: serde_json::Value = serde_json::from_str(ready_line.trim())
        .unwrap_or_else(|e| panic!("failed to parse ready message: {e}\nline: {ready_line}"));

    assert_eq!(ready["status"], "ready", "ready message: {ready:#}");
    let socket_path = ready["socket"]
        .as_str()
        .expect("ready message should have socket path");

    // Step 3: Connect to the socket using the cross-platform IPC abstraction.
    // Retry a few times in case the socket isn't ready yet.
    let endpoint = endpoint_from_socket_path(socket_path);
    let mut stream = None;
    for _ in 0..10 {
        match bmux_ipc::transport::LocalIpcStream::connect(&endpoint).await {
            Ok(s) => {
                stream = Some(s);
                break;
            }
            Err(_) => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
        }
    }
    let stream = stream.unwrap_or_else(|| panic!("failed to connect to socket: {socket_path}"));

    let (reader, mut writer) = tokio::io::split(stream);
    let mut reader = TokioBufReader::new(reader);

    // Helper: send JSON op and read response.
    async fn send_op(
        writer: &mut (impl tokio::io::AsyncWrite + Unpin),
        reader: &mut (impl AsyncBufReadExt + Unpin),
        payload: &serde_json::Value,
    ) -> serde_json::Value {
        let request_id = payload
            .get("request_id")
            .and_then(serde_json::Value::as_str)
            .map(std::string::ToString::to_string);
        writer
            .write_all(format!("{}\n", payload).as_bytes())
            .await
            .expect("failed to write command payload");
        writer.flush().await.expect("failed to flush");

        loop {
            let mut line = String::new();
            reader
                .read_line(&mut line)
                .await
                .expect("failed to read response");
            let parsed: serde_json::Value = serde_json::from_str(line.trim())
                .unwrap_or_else(|e| panic!("failed to parse response: {e}\nline: {line}"));
            match request_id.as_deref() {
                Some(expected) => {
                    if parsed
                        .get("request_id")
                        .and_then(serde_json::Value::as_str)
                        .is_some_and(|actual| actual == expected)
                    {
                        return parsed;
                    }
                }
                None => return parsed,
            }
        }
    }

    // Step 4: Send commands and verify responses.

    // new-session
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"command","request_id":"basic-new","dsl":"new-session"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "new-session response: {resp:#}");
    assert_eq!(resp["action"], "new-session");

    // send-keys
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"command","request_id":"basic-keys","dsl":"send-keys keys='echo interactive_test\\r'"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "send-keys response: {resp:#}");

    // sleep briefly for output to arrive
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;

    // screen
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"hydrate","request_id":"basic-screen","kind":"screen_full"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "screen response: {resp:#}");
    let panes = resp["panes"].as_array().expect("screen should have panes");
    assert!(!panes.is_empty(), "should have at least one pane");
    let screen_text = panes[0]["screen_text"].as_str().unwrap_or("");
    assert!(
        screen_text.contains("interactive_test"),
        "screen should contain 'interactive_test': {screen_text}"
    );

    // assert-screen
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"command","request_id":"basic-assert-ok","dsl":"assert-screen contains='interactive_test'"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "assert-screen response: {resp:#}");

    // status
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"status","request_id":"basic-status"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "status response: {resp:#}");
    assert!(
        resp.get("session_id").is_some() && !resp["session_id"].is_null(),
        "status should have session_id: {resp:#}"
    );
    assert!(
        resp.get("pane_count").is_some(),
        "status should have pane_count: {resp:#}"
    );
    assert!(
        resp.get("focused_pane").is_some(),
        "status should have focused_pane: {resp:#}"
    );

    // Test interactive failure response includes pane captures.
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"command","request_id":"basic-assert-fail","dsl":"assert-screen contains='nonexistent_interactive_xyz'"}),
    )
    .await;
    assert_eq!(resp["status"], "fail", "assert should fail: {resp:#}");
    // Verify pane captures are included on failure.
    let fail_panes = resp["panes"].as_array();
    assert!(
        fail_panes.is_some() && !fail_panes.unwrap().is_empty(),
        "interactive failure should include pane captures: {resp:#}"
    );

    // subscribe for push output events
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"subscribe","request_id":"basic-subscribe","event_types":["pane_output","cursor_delta","screen_delta","watchpoint_hit"]}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "subscribe response: {resp:#}");
    assert_eq!(resp["action"], "subscribe");

    // send-keys to trigger output while subscribed
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"command","request_id":"basic-keys-2","dsl":"send-keys keys='echo push_test_marker\\r'"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "send-keys response: {resp:#}");

    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"command","request_id":"basic-wait-marker","dsl":"wait-for pattern='push_test_marker' timeout=3000"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "wait-for marker response: {resp:#}");

    // Read responses/push events until we find an output push event or the
    // screen command response. Push events may arrive before or after the
    // screen response.
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;

    // Drain any push events that arrived.
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"hydrate","request_id":"basic-screen-2","kind":"screen_full"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "screen after subscribe: {resp:#}");
    // The screen text should contain our marker.
    let empty_vec = vec![];
    let panes = resp["panes"].as_array().unwrap_or(&empty_vec);
    let screen_has_marker = panes.iter().any(|p| {
        p["screen_text"]
            .as_str()
            .unwrap_or("")
            .contains("push_test_marker")
    });
    assert!(
        screen_has_marker,
        "screen should contain push_test_marker after subscribe"
    );

    // unsubscribe
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"unsubscribe","request_id":"basic-unsubscribe"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "unsubscribe response: {resp:#}");
    assert_eq!(resp["action"], "unsubscribe");

    // quit
    let resp = send_op(
        &mut writer,
        &mut reader,
        &serde_json::json!({"op":"quit","request_id":"basic-quit"}),
    )
    .await;
    assert_eq!(resp["status"], "ok", "quit response: {resp:#}");
    assert_eq!(resp["action"], "quit");

    // Wait for the child to exit.
    let _ = child.wait();
}

#[tokio::test]
#[serial]
async fn interactive_mode_json_screen_delta_formats() {
    use std::process::Stdio;
    use tokio::io::BufReader as TokioBufReader;

    let sandbox = BmuxCommandSandbox::new("interactive-mode-screen-delta");
    let mut child = sandbox
        .command()
        .args([
            "playbook",
            "interactive",
            "--viewport",
            "80x24",
            "--shell",
            "sh",
            "--timeout",
            "120",
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn bmux playbook interactive");

    let _guard = ProcessGuard::new(&child);

    let stdout = child.stdout.take().expect("stdout should be piped");
    let mut stdout_reader = std::io::BufReader::new(stdout);
    let mut ready_line = String::new();
    std::io::BufRead::read_line(&mut stdout_reader, &mut ready_line)
        .expect("failed to read ready message");
    let ready: serde_json::Value = serde_json::from_str(ready_line.trim())
        .unwrap_or_else(|e| panic!("failed to parse ready message: {e}\nline: {ready_line}"));
    let socket_path = ready["socket"]
        .as_str()
        .expect("ready message should have socket path");

    let endpoint = endpoint_from_socket_path(socket_path);
    let mut stream = None;
    for _ in 0..10 {
        match bmux_ipc::transport::LocalIpcStream::connect(&endpoint).await {
            Ok(s) => {
                stream = Some(s);
                break;
            }
            Err(_) => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
        }
    }
    let stream = stream.unwrap_or_else(|| panic!("failed to connect to socket: {socket_path}"));
    let (reader, mut writer) = tokio::io::split(stream);
    let mut reader = TokioBufReader::new(reader);

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"command","request_id":"r-new","dsl":"new-session"}),
    )
    .await;
    let new_session = read_response_for_request_id(&mut reader, "r-new").await;
    assert_eq!(new_session["action"], "new-session", "new-session response");
    assert_eq!(new_session["request_id"], "r-new");

    send_json_line(
        &mut writer,
        &serde_json::json!({
            "op":"subscribe",
            "request_id":"r-sub-1",
            "client":"llm-agent",
            "event_types":["screen_delta"],
            "screen_delta_format":"line_ops"
        }),
    )
    .await;
    let subscribe_1 = read_response_for_request_id(&mut reader, "r-sub-1").await;
    assert_eq!(subscribe_1["action"], "subscribe");

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"command","request_id":"r-keys-1","dsl":"send-keys keys='echo line_ops_marker\\r'"}),
    )
    .await;
    let keys_1 = read_response_for_request_id(&mut reader, "r-keys-1").await;
    assert_eq!(keys_1["status"], "ok");

    let line_ops_event = read_until_json(&mut reader, 80, "line_ops screen_delta", |msg| {
        msg["type"] == "event" && msg["event_type"] == "screen_delta"
    })
    .await;
    assert_eq!(line_ops_event["screen_delta"]["format"], "line_ops");

    send_json_line(
        &mut writer,
        &serde_json::json!({
            "op":"subscribe",
            "request_id":"r-sub-2",
            "event_types":["screen_delta"],
            "screen_delta_format":"unified_diff"
        }),
    )
    .await;
    let subscribe_2 = read_response_for_request_id(&mut reader, "r-sub-2").await;
    assert_eq!(subscribe_2["action"], "subscribe");

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"command","request_id":"r-keys-2","dsl":"send-keys keys='echo unified_diff_marker\\r'"}),
    )
    .await;
    let keys_2 = read_response_for_request_id(&mut reader, "r-keys-2").await;
    assert_eq!(keys_2["status"], "ok");

    let unified_diff_event = read_until_json(&mut reader, 80, "unified_diff screen_delta", |msg| {
        msg["type"] == "event" && msg["event_type"] == "screen_delta"
    })
    .await;
    assert_eq!(unified_diff_event["screen_delta"]["format"], "unified_diff");
    let diff = unified_diff_event["screen_delta"]["diff"]
        .as_str()
        .unwrap_or("");
    assert!(
        diff.contains("@@"),
        "unified diff payload should include hunks"
    );

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"quit","request_id":"r-quit"}),
    )
    .await;
    let quit = read_response_for_request_id(&mut reader, "r-quit").await;
    assert_eq!(quit["action"], "quit", "expected quit response");

    let _ = child.wait();
}

#[tokio::test]
#[serial]
async fn interactive_mode_watchpoint_event_burst_cursor_delta_hit() {
    use std::process::Stdio;
    use tokio::io::BufReader as TokioBufReader;

    let sandbox = BmuxCommandSandbox::new("interactive-mode-watchpoint-cursor");
    let mut child = sandbox
        .command()
        .args([
            "playbook",
            "interactive",
            "--viewport",
            "80x24",
            "--shell",
            "sh",
            "--timeout",
            "120",
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn bmux playbook interactive");

    let _guard = ProcessGuard::new(&child);

    let stdout = child.stdout.take().expect("stdout should be piped");
    let mut stdout_reader = std::io::BufReader::new(stdout);
    let mut ready_line = String::new();
    std::io::BufRead::read_line(&mut stdout_reader, &mut ready_line)
        .expect("failed to read ready message");
    let ready: serde_json::Value = serde_json::from_str(ready_line.trim())
        .unwrap_or_else(|e| panic!("failed to parse ready message: {e}\nline: {ready_line}"));
    let socket_path = ready["socket"]
        .as_str()
        .expect("ready message should have socket path");

    let endpoint = endpoint_from_socket_path(socket_path);
    let mut stream = None;
    for _ in 0..10 {
        match bmux_ipc::transport::LocalIpcStream::connect(&endpoint).await {
            Ok(s) => {
                stream = Some(s);
                break;
            }
            Err(_) => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
        }
    }
    let stream = stream.unwrap_or_else(|| panic!("failed to connect to socket: {socket_path}"));
    let (reader, mut writer) = tokio::io::split(stream);
    let mut reader = TokioBufReader::new(reader);

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"command","request_id":"wp-new","dsl":"new-session"}),
    )
    .await;
    let new_session = read_response_for_request_id(&mut reader, "wp-new").await;
    assert_eq!(new_session["action"], "new-session");

    send_json_line(
        &mut writer,
        &serde_json::json!({
            "op":"subscribe",
            "request_id":"wp-sub",
            "event_types":["watchpoint_hit"],
            "pane_indexes":[1]
        }),
    )
    .await;
    let subscribed = read_response_for_request_id(&mut reader, "wp-sub").await;
    assert_eq!(subscribed["action"], "subscribe");

    send_json_line(
        &mut writer,
        &serde_json::json!({
            "op":"set_watchpoint",
            "request_id":"wp-set",
            "id":"cursor-delta-burst-1",
            "kind":"event_burst",
            "event_type":"cursor_delta",
            "pane_index":1,
            "min_hits":1,
            "window_ms":500
        }),
    )
    .await;
    let set_resp = read_response_for_request_id(&mut reader, "wp-set").await;
    assert_eq!(set_resp["action"], "set_watchpoint");

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"command","request_id":"wp-keys","dsl":"send-keys keys='echo watchpoint_cursor_marker\\r'"}),
    )
    .await;
    let key_resp = read_response_for_request_id(&mut reader, "wp-keys").await;
    assert_eq!(key_resp["status"], "ok");

    let hit = read_until_json(&mut reader, 120, "cursor_delta watchpoint hit", |msg| {
        msg["type"] == "event"
            && msg["event_type"] == "watchpoint_hit"
            && msg["watchpoint_hit"]["id"] == "cursor-delta-burst-1"
    })
    .await;
    assert_eq!(hit["watchpoint_hit"]["kind"], "event_burst");
    assert_eq!(hit["watchpoint_hit"]["watch_event_type"], "cursor_delta");

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"clear_watchpoint","request_id":"wp-clear","id":"cursor-delta-burst-1"}),
    )
    .await;
    let clear_resp = read_response_for_request_id(&mut reader, "wp-clear").await;
    assert_eq!(clear_resp["action"], "clear_watchpoint");

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"quit","request_id":"wp-quit"}),
    )
    .await;
    let quit = read_response_for_request_id(&mut reader, "wp-quit").await;
    assert_eq!(quit["action"], "quit", "expected quit response");

    let _ = child.wait();
}

#[tokio::test]
#[serial]
async fn interactive_mode_watchpoint_event_burst_screen_delta_hit_and_blocks_recursive() {
    use std::process::Stdio;
    use tokio::io::BufReader as TokioBufReader;

    let sandbox = BmuxCommandSandbox::new("interactive-mode-watchpoint-screen");
    let mut child = sandbox
        .command()
        .args([
            "playbook",
            "interactive",
            "--viewport",
            "80x24",
            "--shell",
            "sh",
            "--timeout",
            "120",
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn bmux playbook interactive");

    let _guard = ProcessGuard::new(&child);

    let stdout = child.stdout.take().expect("stdout should be piped");
    let mut stdout_reader = std::io::BufReader::new(stdout);
    let mut ready_line = String::new();
    std::io::BufRead::read_line(&mut stdout_reader, &mut ready_line)
        .expect("failed to read ready message");
    let ready: serde_json::Value = serde_json::from_str(ready_line.trim())
        .unwrap_or_else(|e| panic!("failed to parse ready message: {e}\nline: {ready_line}"));
    let socket_path = ready["socket"]
        .as_str()
        .expect("ready message should have socket path");

    let endpoint = endpoint_from_socket_path(socket_path);
    let mut stream = None;
    for _ in 0..10 {
        match bmux_ipc::transport::LocalIpcStream::connect(&endpoint).await {
            Ok(s) => {
                stream = Some(s);
                break;
            }
            Err(_) => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
        }
    }
    let stream = stream.unwrap_or_else(|| panic!("failed to connect to socket: {socket_path}"));
    let (reader, mut writer) = tokio::io::split(stream);
    let mut reader = TokioBufReader::new(reader);

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"command","request_id":"burst-new","dsl":"new-session"}),
    )
    .await;
    let new_session = read_response_for_request_id(&mut reader, "burst-new").await;
    assert_eq!(new_session["action"], "new-session");

    send_json_line(
        &mut writer,
        &serde_json::json!({
            "op":"subscribe",
            "request_id":"burst-sub",
            "event_types":["watchpoint_hit"],
            "pane_indexes":[1]
        }),
    )
    .await;
    let subscribed = read_response_for_request_id(&mut reader, "burst-sub").await;
    assert_eq!(subscribed["action"], "subscribe");

    send_json_line(
        &mut writer,
        &serde_json::json!({
            "op":"set_watchpoint",
            "request_id":"burst-set-invalid",
            "id":"blocked-watchpoint-hit",
            "kind":"event_burst",
            "event_type":"watchpoint_hit",
            "min_hits":2,
            "window_ms":1000
        }),
    )
    .await;
    let invalid_watchpoint = read_response_for_request_id(&mut reader, "burst-set-invalid").await;
    assert_eq!(invalid_watchpoint["status"], "error");

    send_json_line(
        &mut writer,
        &serde_json::json!({
            "op":"set_watchpoint",
            "request_id":"burst-set",
            "id":"screen-delta-burst-1",
            "kind":"event_burst",
            "event_type":"screen_delta",
            "pane_index":1,
            "min_hits":1,
            "window_ms":5000
        }),
    )
    .await;
    let set_resp = read_response_for_request_id(&mut reader, "burst-set").await;
    assert_eq!(set_resp["action"], "set_watchpoint");

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"command","request_id":"burst-keys-1","dsl":"send-keys keys='echo burst_one\\r'"}),
    )
    .await;
    let keys_1 = read_response_for_request_id(&mut reader, "burst-keys-1").await;
    assert_eq!(keys_1["status"], "ok");

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"command","request_id":"burst-keys-2","dsl":"send-keys keys='echo burst_two\\r'"}),
    )
    .await;
    let keys_2 = read_response_for_request_id(&mut reader, "burst-keys-2").await;
    assert_eq!(keys_2["status"], "ok");

    let hit = read_until_json(&mut reader, 120, "screen_delta watchpoint hit", |msg| {
        msg["type"] == "event"
            && msg["event_type"] == "watchpoint_hit"
            && msg["watchpoint_hit"]["id"] == "screen-delta-burst-1"
    })
    .await;
    assert_eq!(hit["watchpoint_hit"]["kind"], "event_burst");
    assert_eq!(hit["watchpoint_hit"]["watch_event_type"], "screen_delta");
    assert!(
        hit["watchpoint_hit"]["observed_hits"].as_u64().unwrap_or(0) >= 1,
        "expected observed hits >= 1"
    );

    send_json_line(
        &mut writer,
        &serde_json::json!({"op":"quit","request_id":"burst-quit"}),
    )
    .await;
    let quit = read_response_for_request_id(&mut reader, "burst-quit").await;
    assert_eq!(quit["action"], "quit", "expected quit response");

    let _ = child.wait();
}

// ---------------------------------------------------------------------------
// A: Concurrent playbook runs
// ---------------------------------------------------------------------------

#[tokio::test]
async fn concurrent_playbook_runs() {
    let dsl = "\
@viewport cols=80 rows=24
@shell sh
new-session
send-keys keys='echo concurrent_test\\r'
wait-for pattern='concurrent_test'
assert-screen contains='concurrent_test'
";

    let run1 = async {
        let (mut pb, _) = bmux_cli::playbook::parse_dsl::parse_dsl(dsl).unwrap();
        pb.config.binary = Some(PathBuf::from(env!("CARGO_BIN_EXE_bmux")));
        bmux_cli::playbook::run(pb, false).await.unwrap()
    };
    let run2 = async {
        let (mut pb, _) = bmux_cli::playbook::parse_dsl::parse_dsl(dsl).unwrap();
        pb.config.binary = Some(PathBuf::from(env!("CARGO_BIN_EXE_bmux")));
        bmux_cli::playbook::run(pb, false).await.unwrap()
    };

    let (r1, r2) = tokio::join!(run1, run2);
    assert!(r1.pass, "concurrent run 1 should pass: {:?}", r1.error);
    assert!(r2.pass, "concurrent run 2 should pass: {:?}", r2.error);
}

// ---------------------------------------------------------------------------
// B: Shell exit mid-playbook
// ---------------------------------------------------------------------------

#[test]
fn playbook_shell_exit_mid_playbook() {
    let (json, pass) = run_playbook_fixture("shell_exit.dsl");
    assert!(!pass, "should fail after shell exit: {json:#}");

    // Verify we got a failure, not a hang or panic.
    let steps = json["steps"].as_array().expect("should have steps");
    let failed = steps.iter().find(|s| s["status"] == "fail");
    assert!(
        failed.is_some(),
        "should have a failed step after shell exit: {json:#}"
    );
    // The failed step should have a detail with an error message.
    let detail = failed.unwrap()["detail"].as_str().unwrap_or("");
    assert!(
        !detail.is_empty(),
        "failed step should have a detail: {json:#}"
    );
}

// ---------------------------------------------------------------------------
// C: Validate --json integration tests
// ---------------------------------------------------------------------------

#[test]
fn playbook_validate_valid_json() {
    let fixture = fixtures_dir().join("echo_hello.dsl");

    let sandbox = BmuxCommandSandbox::new("validate-valid-json");
    let output = sandbox
        .command()
        .args(["playbook", "validate", "--json", fixture.to_str().unwrap()])
        .output()
        .expect("failed to run bmux playbook validate");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let json: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}"));

    assert_eq!(json["valid"], true, "echo_hello should be valid: {json:#}");
    let errors = json["errors"].as_array().expect("should have errors array");
    assert!(errors.is_empty(), "should have no errors: {json:#}");
    assert!(output.status.success(), "exit code should be 0");
}

#[test]
fn playbook_validate_invalid_json() {
    let fixture = fixtures_dir().join("invalid_no_session.dsl");

    let sandbox = BmuxCommandSandbox::new("validate-invalid-json");
    let output = sandbox
        .command()
        .args(["playbook", "validate", "--json", fixture.to_str().unwrap()])
        .output()
        .expect("failed to run bmux playbook validate");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let json: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}"));

    assert_eq!(
        json["valid"], false,
        "invalid playbook should not be valid: {json:#}"
    );
    let errors = json["errors"].as_array().expect("should have errors array");
    assert!(
        !errors.is_empty(),
        "should have validation errors: {json:#}"
    );
    assert!(
        !output.status.success(),
        "exit code should be non-zero for invalid playbook"
    );
}

// ---------------------------------------------------------------------------
// Playbook diff tests
// ---------------------------------------------------------------------------

/// Helper: run a playbook fixture and return the raw JSON string.
fn run_playbook_fixture_raw(name: &str) -> String {
    let fixture = fixtures_dir().join(name);
    let sandbox = BmuxCommandSandbox::new("run-playbook-fixture-raw");
    let output = sandbox
        .command()
        .args(["playbook", "run", "--json", fixture.to_str().unwrap()])
        .env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
        .output()
        .expect("failed to run bmux playbook");
    String::from_utf8_lossy(&output.stdout).to_string()
}

#[test]
fn playbook_diff_detects_changes() {
    // Run two different playbooks to get two result JSONs.
    let left_json = run_playbook_fixture_raw("echo_hello.dsl");
    let right_json = run_playbook_fixture_raw("failing_assert.dsl");

    // Write to temp files.
    let dir = std::env::temp_dir().join(format!("bmux-diff-test-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let left_path = dir.join("left.json");
    let right_path = dir.join("right.json");
    std::fs::write(&left_path, &left_json).unwrap();
    std::fs::write(&right_path, &right_json).unwrap();

    // Run diff.
    let sandbox = BmuxCommandSandbox::new("diff-detects-changes");
    let output = sandbox
        .command()
        .args([
            "playbook",
            "diff",
            "--json",
            left_path.to_str().unwrap(),
            right_path.to_str().unwrap(),
        ])
        .output()
        .expect("failed to run bmux playbook diff");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let json: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}"));

    // Verify outcome changed (echo_hello passes, failing_assert fails).
    assert_eq!(
        json["summary"]["outcome_changed"], true,
        "outcome should have changed: {json:#}"
    );
    assert_eq!(json["summary"]["left_pass"], true);
    assert_eq!(json["summary"]["right_pass"], false);

    // Verify at least one step changed.
    assert!(
        json["summary"]["steps_changed"].as_u64().unwrap_or(0) > 0,
        "should have at least one changed step: {json:#}"
    );

    // Verify step_diffs is populated.
    let step_diffs = json["step_diffs"]
        .as_array()
        .expect("should have step_diffs");
    assert!(!step_diffs.is_empty(), "step_diffs should not be empty");

    // Clean up.
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn playbook_diff_identical() {
    let result_json = run_playbook_fixture_raw("echo_hello.dsl");

    let dir = std::env::temp_dir().join(format!("bmux-diff-identical-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let path = dir.join("result.json");
    std::fs::write(&path, &result_json).unwrap();

    let sandbox = BmuxCommandSandbox::new("diff-identical");
    let output = sandbox
        .command()
        .args([
            "playbook",
            "diff",
            "--json",
            path.to_str().unwrap(),
            path.to_str().unwrap(),
        ])
        .output()
        .expect("failed to run bmux playbook diff");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let json: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("JSON parse failed: {e}\nstdout: {stdout}"));

    // Identical results should have no changes.
    assert_eq!(
        json["summary"]["outcome_changed"], false,
        "outcome should not change for identical results: {json:#}"
    );
    assert_eq!(
        json["summary"]["steps_changed"].as_u64().unwrap_or(99),
        0,
        "no steps should change for identical results: {json:#}"
    );

    // Exit code should be 0 (no changes).
    assert!(
        output.status.success(),
        "exit code should be 0 for identical results"
    );

    let _ = std::fs::remove_dir_all(&dir);
}

// ---------------------------------------------------------------------------
// A2: --var CLI flag test
// ---------------------------------------------------------------------------

#[test]
fn playbook_var_cli_override() {
    let fixture = fixtures_dir().join("var_override.dsl");
    let sandbox = BmuxCommandSandbox::new("var-cli-override");
    let output = sandbox
        .command()
        .args([
            "playbook",
            "run",
            "--json",
            "--var",
            "MARKER=cli_override",
            fixture.to_str().unwrap(),
        ])
        .env("BMUX_PLAYBOOK_ENV_MODE", "inherit")
        .output()
        .expect("failed to run bmux playbook");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
        let stderr = String::from_utf8_lossy(&output.stderr);
        panic!("JSON parse failed: {e}\nstdout: {stdout}\nstderr: {stderr}")
    });

    let pass = json["pass"].as_bool().unwrap_or(false);
    assert!(pass, "--var should override @var: {json:#}");
}

// ---------------------------------------------------------------------------
// A4: continue-on-error test
// ---------------------------------------------------------------------------

#[test]
fn playbook_continue_on_error() {
    let (json, pass) = run_playbook_fixture("continue_on_error.dsl");
    assert!(
        !pass,
        "playbook with failed !continue step should fail: {json:#}"
    );

    let steps = json["steps"].as_array().expect("steps should be array");
    let assert_steps: Vec<_> = steps
        .iter()
        .filter(|s| s["action"] == "assert-screen")
        .collect();

    assert_eq!(
        assert_steps.len(),
        2,
        "both asserts should execute: {json:#}"
    );
    assert_eq!(
        assert_steps[0]["status"], "fail",
        "first assert should fail"
    );
    assert_eq!(
        assert_steps[1]["status"], "pass",
        "second assert should pass"
    );
}

// ---------------------------------------------------------------------------
// C: Sandbox cleanup command test
// ---------------------------------------------------------------------------

#[test]
fn playbook_cleanup_dry_run() {
    let sandbox = BmuxCommandSandbox::new("cleanup-dry-run");
    let output = sandbox
        .command()
        .args(["playbook", "cleanup", "--json", "--dry-run"])
        .output()
        .expect("failed to run bmux playbook cleanup");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
        let stderr = String::from_utf8_lossy(&output.stderr);
        panic!("JSON parse failed: {e}\nstdout: {stdout}\nstderr: {stderr}")
    });

    assert!(
        json.get("scanned").is_some(),
        "should have scanned: {json:#}"
    );
    assert!(
        json.get("orphaned").is_some(),
        "should have orphaned: {json:#}"
    );
    assert!(output.status.success(), "cleanup should succeed");
}
