//! Playbook data types: actions, configuration, and result structures.
//!
//! These types are the internal representation shared between the DSL parser,
//! TOML parser, and execution engine. See `docs/playbooks.md` for the full
//! user-facing reference.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::time::Duration;
use uuid::Uuid;

/// Top-level playbook definition parsed from either TOML or line DSL.
#[derive(Debug, Clone)]
pub struct Playbook {
    pub config: PlaybookConfig,
    pub steps: Vec<Step>,
}

/// Playbook-wide configuration.
#[derive(Debug, Clone)]
pub struct PlaybookConfig {
    pub name: Option<String>,
    pub description: Option<String>,
    pub viewport: Viewport,
    pub shell: Option<String>,
    pub timeout: Duration,
    pub record: bool,
    pub plugins: PluginConfig,
    /// User-defined variables for substitution.
    pub vars: BTreeMap<String, String>,
    /// Environment variables to set in the sandbox server process.
    /// In `Inherit` mode these are overlaid on the parent environment.
    /// In `Clean` mode the sandbox starts empty and only has these plus
    /// deterministic defaults.
    pub env: BTreeMap<String, String>,
    /// Controls how the sandbox inherits the parent process environment.
    /// `None` means the playbook did not explicitly specify a mode.
    pub env_mode: Option<SandboxEnvMode>,
    /// Path to the bmux binary for spawning sandbox servers.
    /// `None` falls back to `std::env::current_exe()`.
    pub binary: Option<std::path::PathBuf>,
    /// Pre-computed bundled plugin IDs for sandbox plugin configuration.
    /// Populated by the CLI runtime; empty when not available.
    pub bundled_plugin_ids: Vec<String>,
    /// Print step-by-step progress to stderr during execution.
    pub verbose: bool,
    /// Collect normalized render summaries for render-mark/assert-render steps.
    pub render_trace: bool,
    /// Execution backend for the playbook.
    pub driver: PlaybookDriver,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaybookDriver {
    Sandbox,
    RealAttach,
    AttachSim,
}

/// Controls how the sandbox server inherits environment variables.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxEnvMode {
    /// Inherit the full parent environment, then overlay deterministic defaults
    /// (`TERM`, `LANG`, `LC_ALL`, `HOME`) and any explicit `@env` overrides.
    /// This is backward-compatible and the default.
    Inherit,
    /// Start from an empty environment. Only `PATH`, `USER`, and `SHELL` are
    /// inherited from the parent; everything else uses deterministic defaults
    /// or explicit `@env` overrides. Maximally deterministic.
    Clean,
}

impl PlaybookConfig {
    /// Resolve the effective sandbox environment mode.
    ///
    /// Priority: playbook config (if explicitly set) → `BMUX_PLAYBOOK_ENV_MODE`
    /// env var (if set) → `Inherit`.
    #[must_use]
    pub fn effective_env_mode(&self) -> SandboxEnvMode {
        effective_env_mode_from(
            self.env_mode,
            std::env::var("BMUX_PLAYBOOK_ENV_MODE").ok().as_deref(),
        )
    }
}

fn effective_env_mode_from(
    explicit: Option<SandboxEnvMode>,
    env_mode: Option<&str>,
) -> SandboxEnvMode {
    if let Some(mode) = explicit {
        return mode;
    }
    match env_mode {
        Some("clean") => SandboxEnvMode::Clean,
        _ => SandboxEnvMode::Inherit,
    }
}

impl Default for PlaybookConfig {
    fn default() -> Self {
        Self {
            name: None,
            description: None,
            viewport: Viewport::default(),
            shell: None,
            timeout: Duration::from_secs(30),
            record: false,
            plugins: PluginConfig::default(),
            vars: BTreeMap::new(),
            env: BTreeMap::new(),
            env_mode: None,
            binary: None,
            bundled_plugin_ids: Vec::new(),
            verbose: false,
            render_trace: false,
            driver: PlaybookDriver::Sandbox,
        }
    }
}

/// Terminal viewport dimensions.
#[derive(Debug, Clone, Copy)]
pub struct Viewport {
    pub cols: u16,
    pub rows: u16,
}

impl Default for Viewport {
    fn default() -> Self {
        Self { cols: 80, rows: 24 }
    }
}

/// Plugin loading configuration for the ephemeral server.
#[derive(Debug, Clone, Default)]
pub struct PluginConfig {
    pub enable: Vec<String>,
    pub disable: Vec<String>,
}

/// A single step in the playbook.
#[derive(Debug, Clone)]
pub struct Step {
    pub index: usize,
    pub action: Action,
    /// If true, the playbook continues executing even if this step fails.
    pub continue_on_error: bool,
}

impl Step {
    /// Serialize the step to a DSL line, including the `!continue` suffix if set.
    #[must_use]
    #[allow(clippy::too_many_lines)]
    pub fn to_dsl(&self) -> String {
        let line = self.action.to_dsl();
        if self.continue_on_error {
            format!("{line} !continue")
        } else {
            line
        }
    }
}

/// All supported playbook actions.
#[derive(Debug, Clone)]
pub enum Action {
    /// Create a new session.
    NewSession { name: Option<String> },
    /// Kill a session by name.
    KillSession { name: String },
    /// Split the focused (or target) pane.
    SplitPane {
        direction: SplitDirection,
        #[allow(dead_code)]
        ratio: Option<f64>,
    },
    /// Focus a pane by index.
    FocusPane { target: u32 },
    /// Close a pane (focused if no target given).
    ClosePane { target: Option<u32> },
    /// Send keystrokes to the focused pane (C-style escapes supported).
    SendKeys { keys: Vec<u8>, pane: Option<u32> },
    /// Send raw bytes (hex-encoded) to the focused pane.
    SendBytes { hex: Vec<u8> },
    /// Wait until pane screen content matches a regex pattern.
    WaitFor {
        pattern: String,
        pane: Option<u32>,
        timeout: Duration,
        /// Number of retry attempts (default 1 = no retry). Each attempt
        /// re-drains output and re-polls from scratch.
        retry: u32,
    },
    /// Hard pause.
    Sleep { duration: Duration },
    /// Capture and label full screen state at this point.
    Snapshot { id: String },
    /// Assert pane screen text.
    AssertScreen {
        pane: Option<u32>,
        contains: Option<String>,
        not_contains: Option<String>,
        matches: Option<String>,
        scrollback: bool,
    },
    /// Assert layout structure.
    AssertLayout { pane_count: u32 },
    /// Assert cursor position in a pane.
    AssertCursor {
        pane: Option<u32>,
        row: u16,
        col: u16,
    },
    /// Change the terminal viewport size mid-playbook.
    ResizeViewport { cols: u16, rows: u16 },
    /// Send key chord(s) through the attach keybinding runtime.
    SendAttach { key: String },
    /// Send the prefix key combo + a key character.
    PrefixKey { key: char },
    /// Wait for a server event.
    WaitForEvent { event: String, timeout: Duration },
    /// Invoke a plugin service.
    InvokeService {
        capability: String,
        kind: ServiceKind,
        interface_id: String,
        operation: String,
        payload: String,
    },
    /// Capture the current screen state of all panes. In batch mode, the result
    /// is included in the `PlaybookResult`'s step details as serialized JSON.
    /// This is useful for LLM debugging: inspect screen state without asserting.
    Screen,
    /// Query the current session status (session ID, pane count, focused pane).
    /// In batch mode, the result is included in the step details.
    Status,
    /// Mark the current render-trace position for later assertions.
    RenderMark { id: String },
    /// Assert bounded render activity since a marker.
    AssertRender {
        since: String,
        assertion: RenderAssertion,
    },
    /// Seed a deterministic attach simulation window list.
    SeedWindowList { names: Vec<String>, active: String },
    /// Seed deterministic attach simulation pane text.
    SeedPaneText {
        lines: Vec<String>,
        cursor_row: u16,
        cursor_col: u16,
    },
    /// Seed deterministic attach simulation pane layout.
    SeedPaneLayout { split: String },
    /// Render the current deterministic attach simulation state.
    Render,
    /// Locate text in the current deterministic render and store coordinates.
    Locate { id: String, text: String },
    /// Send a normalized terminal event to the deterministic attach simulation.
    TerminalEvent(SimTerminalEvent),
    /// Assert an effect was emitted by the deterministic attach simulation.
    AssertEffect { operation: String },
    /// Assert an effect was not emitted by the deterministic attach simulation.
    AssertNoEffect { operation: String },
    /// Assert deterministic simulation state.
    AssertState { path: String, equals: String },
    /// Assert deterministic simulation rendered output.
    AssertRendered {
        contains: Option<String>,
        matches: Option<String>,
    },
    /// Set deterministic simulation configuration.
    SetConfig { path: String, value: String },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimTerminalEvent {
    pub kind: String,
    pub phase: String,
    pub button: Option<String>,
    pub col: String,
    pub row: String,
}

#[derive(Debug, Clone, Default)]
pub struct RenderAssertion {
    pub min_frames: Option<u64>,
    pub max_frames: Option<u64>,
    pub full_frame: Option<bool>,
    pub max_full_frame_frames: Option<u64>,
    pub max_full_surface_fallbacks: Option<u64>,
    pub max_damage_rects: Option<u64>,
    pub max_damage_area_cells: Option<u64>,
    pub max_rows_emitted: Option<u64>,
    pub max_row_segments_emitted: Option<u64>,
    pub max_cells_emitted: Option<u64>,
    pub max_frame_bytes: Option<u64>,
    pub status_rendered: Option<bool>,
    pub overlay_rendered: Option<bool>,
    pub expected_emitted_rows: Option<Vec<PlaybookRenderRowRef>>,
    pub expected_emitted_row_segments: Option<Vec<PlaybookRenderRowSegmentRef>>,
    pub expected_trace_ops: Option<Vec<PlaybookRenderTraceOp>>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlaybookRenderRowRef {
    pub pane: u32,
    pub row: u16,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlaybookRenderRowSegmentRef {
    pub pane: u32,
    pub row: u16,
    pub start_col: u16,
    pub cells: u16,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum PlaybookRenderTraceOp {
    FullFrame,
    ClearRow {
        row: u16,
        cells: u16,
    },
    PaneRowFull {
        pane: u32,
        row: u16,
        cells: u16,
    },
    PaneRowSegment {
        pane: u32,
        row: u16,
        start_col: u16,
        cells: u16,
    },
    PaneRowCacheSkip {
        pane: u32,
        row: u16,
    },
    PaneRowsSyncDeferred {
        pane: u32,
        rows: u16,
    },
    ExtensionOps {
        surface: u32,
        regions: u16,
        full_surface: bool,
    },
    ExtensionCachedReplay {
        surface: u32,
    },
    ExtensionImperative {
        surface: u32,
        regions: u16,
        full_surface: bool,
    },
    StatusLine,
    HelpOverlay,
    PromptOverlay,
    DamageOverlay {
        rects: u16,
        cells: u64,
    },
    Cursor {
        pane: u32,
        visible: bool,
    },
    Overlay,
}

impl PlaybookRenderTraceOp {
    /// Parse the compact semantic render-trace operation used by DSL/TOML assertions.
    ///
    /// # Errors
    ///
    /// Returns a string describing the invalid field when `entry` is not a supported compact
    /// operation or one of its numeric/boolean fields cannot be parsed.
    pub fn parse_compact(entry: &str) -> Result<Self, String> {
        if entry == "full-frame" {
            return Ok(Self::FullFrame);
        }
        if entry == "status-line" {
            return Ok(Self::StatusLine);
        }
        if entry == "help-overlay" {
            return Ok(Self::HelpOverlay);
        }
        if entry == "prompt-overlay" {
            return Ok(Self::PromptOverlay);
        }
        if entry == "overlay" {
            return Ok(Self::Overlay);
        }
        let parts = entry.split(':').collect::<Vec<_>>();
        match parts.as_slice() {
            ["clear-row", row, cells] => Ok(Self::ClearRow {
                row: parse_trace_part(row, "row")?,
                cells: parse_trace_part(cells, "cells")?,
            }),
            ["pane-row-full", pane, row, cells] => Ok(Self::PaneRowFull {
                pane: parse_trace_part(pane, "pane")?,
                row: parse_trace_part(row, "row")?,
                cells: parse_trace_part(cells, "cells")?,
            }),
            ["pane-row-segment", pane, row, start_col, cells] => Ok(Self::PaneRowSegment {
                pane: parse_trace_part(pane, "pane")?,
                row: parse_trace_part(row, "row")?,
                start_col: parse_trace_part(start_col, "start_col")?,
                cells: parse_trace_part(cells, "cells")?,
            }),
            ["pane-row-cache-skip", pane, row] => Ok(Self::PaneRowCacheSkip {
                pane: parse_trace_part(pane, "pane")?,
                row: parse_trace_part(row, "row")?,
            }),
            ["pane-rows-sync-deferred", pane, rows] => Ok(Self::PaneRowsSyncDeferred {
                pane: parse_trace_part(pane, "pane")?,
                rows: parse_trace_part(rows, "rows")?,
            }),
            ["extension-ops", surface, regions, full_surface] => Ok(Self::ExtensionOps {
                surface: parse_trace_part(surface, "surface")?,
                regions: parse_trace_part(regions, "regions")?,
                full_surface: parse_trace_part(full_surface, "full_surface")?,
            }),
            ["extension-cached-replay", surface] => Ok(Self::ExtensionCachedReplay {
                surface: parse_trace_part(surface, "surface")?,
            }),
            ["extension-imperative", surface, regions, full_surface] => {
                Ok(Self::ExtensionImperative {
                    surface: parse_trace_part(surface, "surface")?,
                    regions: parse_trace_part(regions, "regions")?,
                    full_surface: parse_trace_part(full_surface, "full_surface")?,
                })
            }
            ["damage-overlay", rects, cells] => Ok(Self::DamageOverlay {
                rects: parse_trace_part(rects, "rects")?,
                cells: parse_trace_part(cells, "cells")?,
            }),
            ["cursor", pane, visible] => Ok(Self::Cursor {
                pane: parse_trace_part(pane, "pane")?,
                visible: parse_trace_part(visible, "visible")?,
            }),
            _ => Err(format!(
                "invalid render trace op '{entry}', expected a compact semantic render op"
            )),
        }
    }
}

fn parse_trace_part<T>(value: &str, name: &str) -> Result<T, String>
where
    T: std::str::FromStr,
    T::Err: std::fmt::Display,
{
    value
        .parse()
        .map_err(|error| format!("invalid trace op {name}: {error}"))
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlaybookRenderSummary {
    pub frames: u64,
    pub full_frame_frames: u64,
    pub full_surface_fallbacks: u64,
    pub damage_rects: u64,
    pub damage_area_cells: u64,
    pub rows_emitted: u64,
    pub row_segments_emitted: u64,
    pub cells_emitted: u64,
    pub frame_bytes: u64,
    pub status_rendered_frames: u64,
    pub overlay_rendered_frames: u64,
    pub terminal_graphic_transmits: u64,
    pub terminal_graphic_places: u64,
    pub terminal_graphic_deletes: u64,
    pub terminal_graphic_bytes: u64,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub emitted_rows: Vec<PlaybookRenderRowRef>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub emitted_row_segments: Vec<PlaybookRenderRowSegmentRef>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub trace_ops: Vec<PlaybookRenderTraceOp>,
}

/// Plugin service invocation kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceKind {
    Query,
    Command,
}

/// Pane split direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitDirection {
    Vertical,
    Horizontal,
}

/// Result of a full playbook run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybookResult {
    pub playbook_name: Option<String>,
    pub pass: bool,
    pub steps: Vec<StepResult>,
    pub snapshots: Vec<SnapshotCapture>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recording_id: Option<Uuid>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recording_path: Option<String>,
    pub total_elapsed_ms: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// Sandbox temp directory path. Retained on failure for inspection.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sandbox_root: Option<String>,
}

/// Result of a single step execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
    pub index: usize,
    pub action: String,
    pub status: StepStatus,
    pub elapsed_ms: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
    /// The expected value/pattern for assertion failures (assert-screen, wait-for, etc.).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expected: Option<String>,
    /// The actual value/screen text found at the time of failure.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub actual: Option<String>,
    /// Screen capture of all panes at the time of failure.
    /// Only populated when `status == Fail` and the session was attached.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub failure_captures: Option<Vec<PaneCapture>>,
    /// Render summary observed for this step when `@render-trace true` is enabled.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub render_summary: Option<PlaybookRenderSummary>,
    /// Whether this step had `continue_on_error` set. If true and the step
    /// failed, execution continued past this step.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub continue_on_error: bool,
}

/// Step execution status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
    Pass,
    Fail,
    Skip,
}

/// Structured step failure with optional expected/actual values.
///
/// Used internally by `execute_step` to return rich failure context that gets
/// propagated into `StepResult` fields.
#[derive(Debug)]
pub struct StepFailure {
    /// Human-readable error message.
    pub message: String,
    /// The expected value/pattern (for assertion failures).
    pub expected: Option<String>,
    /// The actual value/screen text found.
    pub actual: Option<String>,
}

impl StepFailure {
    /// Create a failure with only a message (no expected/actual).
    pub fn msg(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            expected: None,
            actual: None,
        }
    }

    /// Create an assertion failure with expected and actual values.
    pub fn assertion(
        message: impl Into<String>,
        expected: impl Into<String>,
        actual: impl Into<String>,
    ) -> Self {
        Self {
            message: message.into(),
            expected: Some(expected.into()),
            actual: Some(actual.into()),
        }
    }
}

impl std::fmt::Display for StepFailure {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl std::error::Error for StepFailure {}

/// A named snapshot of screen state captured mid-playbook.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotCapture {
    pub id: String,
    pub panes: Vec<PaneCapture>,
}

/// Captured state of a single pane.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaneCapture {
    pub index: u32,
    pub focused: bool,
    pub screen_text: String,
    pub cursor_row: u16,
    pub cursor_col: u16,
}

impl Action {
    /// Return the action name for display / reporting.
    #[must_use]
    pub const fn name(&self) -> &'static str {
        match self {
            Self::NewSession { .. } => "new-session",
            Self::KillSession { .. } => "kill-session",
            Self::SplitPane { .. } => "split-pane",
            Self::FocusPane { .. } => "focus-pane",
            Self::ClosePane { .. } => "close-pane",
            Self::SendKeys { .. } => "send-keys",
            Self::SendBytes { .. } => "send-bytes",
            Self::WaitFor { .. } => "wait-for",
            Self::Sleep { .. } => "sleep",
            Self::Snapshot { .. } => "snapshot",
            Self::AssertScreen { .. } => "assert-screen",
            Self::AssertLayout { .. } => "assert-layout",
            Self::AssertCursor { .. } => "assert-cursor",
            Self::ResizeViewport { .. } => "resize-viewport",
            Self::SendAttach { .. } => "send-attach",
            Self::PrefixKey { .. } => "prefix-key",
            Self::WaitForEvent { .. } => "wait-for-event",
            Self::InvokeService { .. } => "invoke-service",
            Self::Screen => "screen",
            Self::Status => "status",
            Self::RenderMark { .. } => "render-mark",
            Self::AssertRender { .. } => "assert-render",
            Self::SeedWindowList { .. } => "seed-window-list",
            Self::SeedPaneText { .. } => "seed-pane-text",
            Self::SeedPaneLayout { .. } => "seed-pane-layout",
            Self::Render => "render",
            Self::Locate { .. } => "locate",
            Self::TerminalEvent(_) => "terminal-event",
            Self::AssertEffect { .. } => "assert-effect",
            Self::AssertNoEffect { .. } => "assert-no-effect",
            Self::AssertState { .. } => "assert-state",
            Self::AssertRendered { .. } => "assert-rendered",
            Self::SetConfig { .. } => "set-config",
        }
    }

    /// Serialize the action back to a DSL line for round-trip display.
    #[must_use]
    #[allow(clippy::too_many_lines)]
    pub fn to_dsl(&self) -> String {
        use super::from_recording::{bytes_to_c_escaped, escape_single_quote};

        match self {
            Self::NewSession { name } => name.as_ref().map_or_else(
                || "new-session".to_string(),
                |n| format!("new-session name='{}'", escape_single_quote(n)),
            ),
            Self::KillSession { name } => {
                format!("kill-session name='{}'", escape_single_quote(name))
            }
            Self::SplitPane { direction, ratio } => {
                let dir = match direction {
                    SplitDirection::Vertical => "vertical",
                    SplitDirection::Horizontal => "horizontal",
                };
                ratio.map_or_else(
                    || format!("split-pane direction={dir}"),
                    |r| format!("split-pane direction={dir} ratio={r}"),
                )
            }
            Self::FocusPane { target } => format!("focus-pane target={target}"),
            Self::ClosePane { target } => target.map_or_else(
                || "close-pane".to_string(),
                |t| format!("close-pane target={t}"),
            ),
            Self::SendKeys { keys, pane } => {
                let escaped = bytes_to_c_escaped(keys);
                pane.map_or_else(
                    || format!("send-keys keys='{escaped}'"),
                    |p| format!("send-keys keys='{escaped}' pane={p}"),
                )
            }
            Self::SendBytes { hex } => {
                let mut hex_str = String::with_capacity(hex.len() * 2);
                for b in hex {
                    write!(hex_str, "{b:02x}").unwrap();
                }
                format!("send-bytes hex={hex_str}")
            }
            Self::WaitFor {
                pattern,
                pane,
                timeout,
                retry,
            } => {
                let escaped = escape_single_quote(pattern);
                let mut line = format!("wait-for pattern='{escaped}'");
                if let Some(p) = pane {
                    write!(line, " pane={p}").unwrap();
                }
                let ms = timeout.as_millis();
                if ms != 5000 {
                    write!(line, " timeout={ms}").unwrap();
                }
                if *retry > 1 {
                    write!(line, " retry={retry}").unwrap();
                }
                line
            }
            Self::Sleep { duration } => format!("sleep ms={}", duration.as_millis()),
            Self::Snapshot { id } => {
                format!("snapshot id='{}'", escape_single_quote(id))
            }
            Self::AssertScreen {
                pane,
                contains,
                not_contains,
                matches,
                scrollback,
            } => {
                let mut line = "assert-screen".to_string();
                if let Some(p) = pane {
                    write!(line, " pane={p}").unwrap();
                }
                if let Some(c) = contains {
                    write!(line, " contains='{}'", escape_single_quote(c)).unwrap();
                }
                if let Some(nc) = not_contains {
                    write!(line, " not_contains='{}'", escape_single_quote(nc)).unwrap();
                }
                if let Some(m) = matches {
                    write!(line, " matches='{}'", escape_single_quote(m)).unwrap();
                }
                if *scrollback {
                    line.push_str(" scrollback=true");
                }
                line
            }
            Self::AssertLayout { pane_count } => {
                format!("assert-layout pane_count={pane_count}")
            }
            Self::AssertCursor { pane, row, col } => {
                let mut line = format!("assert-cursor row={row} col={col}");
                if let Some(p) = pane {
                    write!(line, " pane={p}").unwrap();
                }
                line
            }
            Self::ResizeViewport { cols, rows } => {
                format!("resize-viewport cols={cols} rows={rows}")
            }
            Self::SendAttach { key } => {
                format!("send-attach key='{}'", escape_single_quote(key))
            }
            Self::PrefixKey { key } => format!("prefix-key key={key}"),
            Self::WaitForEvent { event, timeout } => {
                let escaped = escape_single_quote(event);
                let ms = timeout.as_millis();
                if ms == 5000 {
                    format!("wait-for-event event='{escaped}'")
                } else {
                    format!("wait-for-event event='{escaped}' timeout={ms}")
                }
            }
            Self::InvokeService {
                capability,
                kind,
                interface_id,
                operation,
                payload,
            } => {
                let kind_str = match kind {
                    ServiceKind::Query => "query",
                    ServiceKind::Command => "command",
                };
                let mut line = format!(
                    "invoke-service capability='{}' interface='{}' operation='{}' kind={kind_str}",
                    escape_single_quote(capability),
                    escape_single_quote(interface_id),
                    escape_single_quote(operation),
                );
                if !payload.is_empty() {
                    write!(line, " payload='{}'", escape_single_quote(payload)).unwrap();
                }
                line
            }
            Self::Screen => "screen".to_string(),
            Self::Status => "status".to_string(),
            Self::RenderMark { id } => {
                format!("render-mark id='{}'", escape_single_quote(id))
            }
            Self::AssertRender { since, assertion } => {
                let mut line = format!("assert-render since='{}'", escape_single_quote(since));
                append_render_assertion_dsl(&mut line, assertion);
                line
            }
            Self::SeedWindowList { names, active } => format!(
                "seed-window-list names='{}' active='{}'",
                escape_single_quote(&names.join(",")),
                escape_single_quote(active)
            ),
            Self::SeedPaneText {
                lines,
                cursor_row,
                cursor_col,
            } => format!(
                "seed-pane-text lines='{}' cursor_row={cursor_row} cursor_col={cursor_col}",
                escape_single_quote(&lines.join("|"))
            ),
            Self::SeedPaneLayout { split } => {
                format!("seed-pane-layout split='{}'", escape_single_quote(split))
            }
            Self::Render => "render".to_string(),
            Self::Locate { id, text } => format!(
                "locate id='{}' text='{}'",
                escape_single_quote(id),
                escape_single_quote(text)
            ),
            Self::TerminalEvent(event) => {
                let mut line = format!(
                    "terminal-event kind={} phase={} col='{}' row='{}'",
                    event.kind,
                    event.phase,
                    escape_single_quote(&event.col),
                    escape_single_quote(&event.row)
                );
                if let Some(button) = &event.button {
                    write!(line, " button={button}").unwrap();
                }
                line
            }
            Self::AssertEffect { operation } => {
                format!(
                    "assert-effect operation='{}'",
                    escape_single_quote(operation)
                )
            }
            Self::AssertNoEffect { operation } => {
                format!(
                    "assert-no-effect operation='{}'",
                    escape_single_quote(operation)
                )
            }
            Self::AssertState { path, equals } => format!(
                "assert-state path='{}' equals='{}'",
                escape_single_quote(path),
                escape_single_quote(equals)
            ),
            Self::AssertRendered { contains, matches } => {
                let mut line = "assert-rendered".to_string();
                if let Some(value) = contains {
                    write!(line, " contains='{}'", escape_single_quote(value)).unwrap();
                }
                if let Some(value) = matches {
                    write!(line, " matches='{}'", escape_single_quote(value)).unwrap();
                }
                line
            }
            Self::SetConfig { path, value } => format!(
                "set-config path='{}' value='{}'",
                escape_single_quote(path),
                escape_single_quote(value)
            ),
        }
    }
}

fn append_render_assertion_dsl(line: &mut String, assertion: &RenderAssertion) {
    macro_rules! push_opt {
        ($field:ident) => {
            if let Some(value) = assertion.$field {
                write!(line, " {}={value}", stringify!($field)).unwrap();
            }
        };
    }
    push_opt!(min_frames);
    push_opt!(max_frames);
    push_opt!(full_frame);
    push_opt!(max_full_frame_frames);
    push_opt!(max_full_surface_fallbacks);
    push_opt!(max_damage_rects);
    push_opt!(max_damage_area_cells);
    push_opt!(max_rows_emitted);
    push_opt!(max_row_segments_emitted);
    push_opt!(max_cells_emitted);
    push_opt!(max_frame_bytes);
    push_opt!(status_rendered);
    push_opt!(overlay_rendered);
    if let Some(rows) = &assertion.expected_emitted_rows {
        write!(
            line,
            " expected_emitted_rows='{}'",
            render_row_refs_to_dsl(rows)
        )
        .unwrap();
    }
    if let Some(segments) = &assertion.expected_emitted_row_segments {
        write!(
            line,
            " expected_emitted_row_segments='{}'",
            render_row_segment_refs_to_dsl(segments)
        )
        .unwrap();
    }
    if let Some(ops) = &assertion.expected_trace_ops {
        write!(
            line,
            " expected_trace_ops='{}'",
            render_trace_ops_to_dsl(ops)
        )
        .unwrap();
    }
}

#[must_use]
pub fn render_row_refs_to_dsl(rows: &[PlaybookRenderRowRef]) -> String {
    rows.iter()
        .map(|row| format!("{}:{}", row.pane, row.row))
        .collect::<Vec<_>>()
        .join(",")
}

#[must_use]
pub fn render_row_segment_refs_to_dsl(segments: &[PlaybookRenderRowSegmentRef]) -> String {
    segments
        .iter()
        .map(|segment| {
            format!(
                "{}:{}:{}:{}",
                segment.pane, segment.row, segment.start_col, segment.cells
            )
        })
        .collect::<Vec<_>>()
        .join(",")
}

#[must_use]
pub fn render_trace_ops_to_dsl(ops: &[PlaybookRenderTraceOp]) -> String {
    ops.iter()
        .map(|op| match *op {
            PlaybookRenderTraceOp::FullFrame => "full-frame".to_string(),
            PlaybookRenderTraceOp::ClearRow { row, cells } => {
                format!("clear-row:{row}:{cells}")
            }
            PlaybookRenderTraceOp::PaneRowFull { pane, row, cells } => {
                format!("pane-row-full:{pane}:{row}:{cells}")
            }
            PlaybookRenderTraceOp::PaneRowSegment {
                pane,
                row,
                start_col,
                cells,
            } => format!("pane-row-segment:{pane}:{row}:{start_col}:{cells}"),
            PlaybookRenderTraceOp::PaneRowCacheSkip { pane, row } => {
                format!("pane-row-cache-skip:{pane}:{row}")
            }
            PlaybookRenderTraceOp::PaneRowsSyncDeferred { pane, rows } => {
                format!("pane-rows-sync-deferred:{pane}:{rows}")
            }
            PlaybookRenderTraceOp::ExtensionOps {
                surface,
                regions,
                full_surface,
            } => format!("extension-ops:{surface}:{regions}:{full_surface}"),
            PlaybookRenderTraceOp::ExtensionCachedReplay { surface } => {
                format!("extension-cached-replay:{surface}")
            }
            PlaybookRenderTraceOp::ExtensionImperative {
                surface,
                regions,
                full_surface,
            } => format!("extension-imperative:{surface}:{regions}:{full_surface}"),
            PlaybookRenderTraceOp::StatusLine => "status-line".to_string(),
            PlaybookRenderTraceOp::HelpOverlay => "help-overlay".to_string(),
            PlaybookRenderTraceOp::PromptOverlay => "prompt-overlay".to_string(),
            PlaybookRenderTraceOp::DamageOverlay { rects, cells } => {
                format!("damage-overlay:{rects}:{cells}")
            }
            PlaybookRenderTraceOp::Cursor { pane, visible } => {
                format!("cursor:{pane}:{visible}")
            }
            PlaybookRenderTraceOp::Overlay => "overlay".to_string(),
        })
        .collect::<Vec<_>>()
        .join(",")
}

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

    #[test]
    fn explicit_clean_wins_over_env_var() {
        assert_eq!(
            effective_env_mode_from(Some(SandboxEnvMode::Clean), Some("inherit")),
            SandboxEnvMode::Clean
        );
    }

    #[test]
    fn explicit_inherit_wins_over_env_var() {
        assert_eq!(
            effective_env_mode_from(Some(SandboxEnvMode::Inherit), Some("clean")),
            SandboxEnvMode::Inherit
        );
    }

    #[test]
    fn none_falls_through_to_env_var_clean() {
        assert_eq!(
            effective_env_mode_from(None, Some("clean")),
            SandboxEnvMode::Clean
        );
    }

    #[test]
    fn none_falls_through_to_env_var_inherit() {
        assert_eq!(
            effective_env_mode_from(None, Some("inherit")),
            SandboxEnvMode::Inherit
        );
    }

    #[test]
    fn none_no_env_var_defaults_to_inherit() {
        assert_eq!(effective_env_mode_from(None, None), SandboxEnvMode::Inherit);
    }

    #[test]
    fn none_invalid_env_var_defaults_to_inherit() {
        assert_eq!(
            effective_env_mode_from(None, Some("garbage")),
            SandboxEnvMode::Inherit
        );
    }

    // ── to_dsl() round-trip tests ──────────────────────────────────────

    /// Parse a single DSL action line (prepended with `new-session` so the
    /// playbook is valid) and return the parsed action at the given step index.
    fn parse_action_dsl(dsl_line: &str, step_index: usize) -> Action {
        let input = format!("new-session\n{dsl_line}\n");
        let (playbook, _) =
            crate::playbook::parse_dsl::parse_dsl(&input).expect("DSL should parse");
        playbook.steps[step_index].action.clone()
    }

    /// Round-trip helper: serialize an action to DSL, parse it back, return both.
    fn round_trip(action: &Action) -> (String, Action) {
        let dsl = action.to_dsl();
        let parsed = parse_action_dsl(&dsl, 1); // index 1 because new-session is index 0
        (dsl, parsed)
    }

    #[test]
    fn to_dsl_round_trip_new_session_no_name() {
        let action = Action::NewSession { name: None };
        // new-session is the first step, so parse at index 0
        let dsl = action.to_dsl();
        let (playbook, _) = crate::playbook::parse_dsl::parse_dsl(&format!("{dsl}\n")).unwrap();
        assert!(matches!(
            playbook.steps[0].action,
            Action::NewSession { name: None }
        ));
    }

    #[test]
    fn to_dsl_round_trip_new_session_with_name() {
        let action = Action::NewSession {
            name: Some("my-session".to_string()),
        };
        let dsl = action.to_dsl();
        let (playbook, _) = crate::playbook::parse_dsl::parse_dsl(&format!("{dsl}\n")).unwrap();
        match &playbook.steps[0].action {
            Action::NewSession { name } => assert_eq!(name.as_deref(), Some("my-session")),
            other => panic!("expected NewSession, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_kill_session() {
        let (_, parsed) = round_trip(&Action::KillSession {
            name: "test".to_string(),
        });
        match parsed {
            Action::KillSession { name } => assert_eq!(name, "test"),
            other => panic!("expected KillSession, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_split_pane() {
        let (_, parsed) = round_trip(&Action::SplitPane {
            direction: SplitDirection::Horizontal,
            ratio: None,
        });
        match parsed {
            Action::SplitPane { direction, .. } => {
                assert_eq!(direction, SplitDirection::Horizontal);
            }
            other => panic!("expected SplitPane, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_focus_pane() {
        let (_, parsed) = round_trip(&Action::FocusPane { target: 3 });
        match parsed {
            Action::FocusPane { target } => assert_eq!(target, 3),
            other => panic!("expected FocusPane, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_close_pane() {
        let (_, parsed) = round_trip(&Action::ClosePane { target: Some(2) });
        match parsed {
            Action::ClosePane { target } => assert_eq!(target, Some(2)),
            other => panic!("expected ClosePane, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_close_pane_no_target() {
        let (_, parsed) = round_trip(&Action::ClosePane { target: None });
        match parsed {
            Action::ClosePane { target } => assert_eq!(target, None),
            other => panic!("expected ClosePane, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_send_keys() {
        let action = Action::SendKeys {
            keys: b"echo hello\r".to_vec(),
            pane: None,
        };
        let (_, parsed) = round_trip(&action);
        match parsed {
            Action::SendKeys { keys, pane } => {
                assert_eq!(keys, b"echo hello\r");
                assert_eq!(pane, None);
            }
            other => panic!("expected SendKeys, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_send_keys_with_pane() {
        let action = Action::SendKeys {
            keys: b"\x1b[A".to_vec(), // ESC [ A (up arrow)
            pane: Some(2),
        };
        let (_, parsed) = round_trip(&action);
        match parsed {
            Action::SendKeys { keys, pane } => {
                assert_eq!(keys, b"\x1b[A");
                assert_eq!(pane, Some(2));
            }
            other => panic!("expected SendKeys, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_send_bytes() {
        let action = Action::SendBytes {
            hex: vec![0x1b, 0x5b, 0x41],
        };
        let (_, parsed) = round_trip(&action);
        match parsed {
            Action::SendBytes { hex } => assert_eq!(hex, vec![0x1b, 0x5b, 0x41]),
            other => panic!("expected SendBytes, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_wait_for_default_timeout() {
        let action = Action::WaitFor {
            pattern: "hello".to_string(),
            pane: None,
            timeout: Duration::from_secs(5),
            retry: 1,
        };
        let (dsl, parsed) = round_trip(&action);
        // Default timeout should be omitted from DSL
        assert!(
            !dsl.contains("timeout="),
            "default timeout should be omitted: {dsl}"
        );
        match parsed {
            Action::WaitFor {
                pattern, timeout, ..
            } => {
                assert_eq!(pattern, "hello");
                assert_eq!(timeout, Duration::from_secs(5));
            }
            other => panic!("expected WaitFor, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_wait_for_custom_timeout() {
        let action = Action::WaitFor {
            pattern: "prompt\\$".to_string(),
            pane: Some(1),
            timeout: Duration::from_secs(10),
            retry: 1,
        };
        let (dsl, parsed) = round_trip(&action);
        assert!(
            dsl.contains("timeout=10000"),
            "custom timeout in DSL: {dsl}"
        );
        match parsed {
            Action::WaitFor {
                pattern,
                pane,
                timeout,
                ..
            } => {
                assert_eq!(pattern, "prompt\\$");
                assert_eq!(pane, Some(1));
                assert_eq!(timeout, Duration::from_secs(10));
            }
            other => panic!("expected WaitFor, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_sleep() {
        let (_, parsed) = round_trip(&Action::Sleep {
            duration: Duration::from_millis(500),
        });
        match parsed {
            Action::Sleep { duration } => assert_eq!(duration, Duration::from_millis(500)),
            other => panic!("expected Sleep, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_snapshot() {
        let (_, parsed) = round_trip(&Action::Snapshot {
            id: "after_echo".to_string(),
        });
        match parsed {
            Action::Snapshot { id } => assert_eq!(id, "after_echo"),
            other => panic!("expected Snapshot, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_assert_screen() {
        let action = Action::AssertScreen {
            pane: Some(1),
            contains: Some("hello".to_string()),
            not_contains: Some("error".to_string()),
            matches: Some("\\d+".to_string()),
            scrollback: true,
        };
        let (_, parsed) = round_trip(&action);
        match parsed {
            Action::AssertScreen {
                pane,
                contains,
                not_contains,
                matches,
                scrollback,
            } => {
                assert_eq!(pane, Some(1));
                assert_eq!(contains.as_deref(), Some("hello"));
                assert_eq!(not_contains.as_deref(), Some("error"));
                assert_eq!(matches.as_deref(), Some("\\d+"));
                assert!(scrollback);
            }
            other => panic!("expected AssertScreen, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_assert_layout() {
        let (_, parsed) = round_trip(&Action::AssertLayout { pane_count: 3 });
        match parsed {
            Action::AssertLayout { pane_count } => assert_eq!(pane_count, 3),
            other => panic!("expected AssertLayout, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_assert_cursor() {
        let action = Action::AssertCursor {
            pane: Some(1),
            row: 5,
            col: 10,
        };
        let (_, parsed) = round_trip(&action);
        match parsed {
            Action::AssertCursor { pane, row, col } => {
                assert_eq!(pane, Some(1));
                assert_eq!(row, 5);
                assert_eq!(col, 10);
            }
            other => panic!("expected AssertCursor, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_resize_viewport() {
        let (_, parsed) = round_trip(&Action::ResizeViewport {
            cols: 132,
            rows: 50,
        });
        match parsed {
            Action::ResizeViewport { cols, rows } => {
                assert_eq!(cols, 132);
                assert_eq!(rows, 50);
            }
            other => panic!("expected ResizeViewport, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_prefix_key() {
        let (_, parsed) = round_trip(&Action::PrefixKey { key: 'c' });
        match parsed {
            Action::PrefixKey { key } => assert_eq!(key, 'c'),
            other => panic!("expected PrefixKey, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_send_attach() {
        let (_, parsed) = round_trip(&Action::SendAttach {
            key: "ctrl+a [".to_string(),
        });
        match parsed {
            Action::SendAttach { key } => assert_eq!(key, "ctrl+a ["),
            other => panic!("expected SendAttach, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_wait_for_event() {
        let action = Action::WaitForEvent {
            event: "session_created".to_string(),
            timeout: Duration::from_secs(5),
        };
        let (dsl, parsed) = round_trip(&action);
        assert!(!dsl.contains("timeout="), "default timeout omitted: {dsl}");
        match parsed {
            Action::WaitForEvent { event, timeout } => {
                assert_eq!(event, "session_created");
                assert_eq!(timeout, Duration::from_secs(5));
            }
            other => panic!("expected WaitForEvent, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_invoke_service() {
        let action = Action::InvokeService {
            capability: "my.cap".to_string(),
            kind: ServiceKind::Query,
            interface_id: "iface.1".to_string(),
            operation: "do_thing".to_string(),
            payload: r#"{"key":"val"}"#.to_string(),
        };
        let (_, parsed) = round_trip(&action);
        match parsed {
            Action::InvokeService {
                capability,
                kind,
                interface_id,
                operation,
                payload,
            } => {
                assert_eq!(capability, "my.cap");
                assert_eq!(kind, ServiceKind::Query);
                assert_eq!(interface_id, "iface.1");
                assert_eq!(operation, "do_thing");
                assert_eq!(payload, r#"{"key":"val"}"#);
            }
            other => panic!("expected InvokeService, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_render_mark() {
        let (_, parsed) = round_trip(&Action::RenderMark {
            id: "baseline".to_string(),
        });
        match parsed {
            Action::RenderMark { id } => assert_eq!(id, "baseline"),
            other => panic!("expected RenderMark, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_assert_render() {
        let (_, parsed) = round_trip(&Action::AssertRender {
            since: "baseline".to_string(),
            assertion: RenderAssertion {
                min_frames: Some(1),
                max_frames: Some(2),
                full_frame: Some(false),
                max_rows_emitted: Some(3),
                expected_emitted_rows: Some(vec![
                    PlaybookRenderRowRef { pane: 1, row: 0 },
                    PlaybookRenderRowRef { pane: 1, row: 1 },
                ]),
                expected_emitted_row_segments: Some(vec![PlaybookRenderRowSegmentRef {
                    pane: 1,
                    row: 1,
                    start_col: 3,
                    cells: 5,
                }]),
                expected_trace_ops: Some(vec![
                    PlaybookRenderTraceOp::FullFrame,
                    PlaybookRenderTraceOp::PaneRowSegment {
                        pane: 1,
                        row: 1,
                        start_col: 3,
                        cells: 5,
                    },
                    PlaybookRenderTraceOp::HelpOverlay,
                    PlaybookRenderTraceOp::ExtensionCachedReplay { surface: 2 },
                ]),
                ..RenderAssertion::default()
            },
        });
        match parsed {
            Action::AssertRender { since, assertion } => {
                assert_eq!(since, "baseline");
                assert_eq!(assertion.min_frames, Some(1));
                assert_eq!(assertion.max_frames, Some(2));
                assert_eq!(assertion.full_frame, Some(false));
                assert_eq!(assertion.max_rows_emitted, Some(3));
                assert_eq!(
                    assertion.expected_emitted_rows,
                    Some(vec![
                        PlaybookRenderRowRef { pane: 1, row: 0 },
                        PlaybookRenderRowRef { pane: 1, row: 1 },
                    ])
                );
                assert_eq!(
                    assertion.expected_emitted_row_segments,
                    Some(vec![PlaybookRenderRowSegmentRef {
                        pane: 1,
                        row: 1,
                        start_col: 3,
                        cells: 5,
                    }])
                );
                assert_eq!(
                    assertion.expected_trace_ops,
                    Some(vec![
                        PlaybookRenderTraceOp::FullFrame,
                        PlaybookRenderTraceOp::PaneRowSegment {
                            pane: 1,
                            row: 1,
                            start_col: 3,
                            cells: 5,
                        },
                        PlaybookRenderTraceOp::HelpOverlay,
                        PlaybookRenderTraceOp::ExtensionCachedReplay { surface: 2 },
                    ])
                );
            }
            other => panic!("expected AssertRender, got {other:?}"),
        }
    }

    #[test]
    fn to_dsl_round_trip_screen_status() {
        let (_, parsed_screen) = round_trip(&Action::Screen);
        assert!(matches!(parsed_screen, Action::Screen));

        let (_, parsed_status) = round_trip(&Action::Status);
        assert!(matches!(parsed_status, Action::Status));
    }

    #[test]
    fn to_dsl_round_trip_wait_for_with_retry() {
        let action = Action::WaitFor {
            pattern: "hello".to_string(),
            pane: None,
            timeout: Duration::from_secs(5),
            retry: 3,
        };
        let (dsl, parsed) = round_trip(&action);
        assert!(dsl.contains("retry=3"), "DSL should contain retry=3: {dsl}");
        match parsed {
            Action::WaitFor { retry, .. } => assert_eq!(retry, 3),
            other => panic!("expected WaitFor, got {other:?}"),
        }
    }
}
