//! YAML playbook parser and executor.
//!
//! Playbooks describe a sequence of client commands in YAML format.
//! They support variable substitution, assertions, loops, and per-step
//! error handling policies.
//!
//! ```yaml
//! name: "smoke test"
//! steps:
//!   - command: dm
//!     args: { username: "bob" }
//!   - command: send
//!     args: { text: "Hello from playbook" }
//!   - command: assert
//!     condition: message_count
//!     op: gte
//!     value: 1
//! ```
//!
//! Requires the `playbook` cargo feature.

use std::collections::HashMap;
use std::path::Path;
use std::time::{Duration, Instant};

use anyhow::{Context, bail};
use quicproquo_proto::node_capnp::node_service;
use serde::{Deserialize, Serialize};

use super::command_engine::{AssertCondition, CmpOp, Command, CommandRegistry};
use super::session::SessionState;

// ── Playbook structs ────────────────────────────────────────────────────────

/// A parsed YAML playbook.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playbook {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub variables: HashMap<String, String>,
    pub steps: Vec<PlaybookStep>,
}

/// A single step in a playbook.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybookStep {
    pub command: String,
    #[serde(default)]
    pub args: HashMap<String, serde_yaml::Value>,
    /// For assert steps: the condition name.
    #[serde(default)]
    pub condition: Option<String>,
    /// For assert steps: comparison operator.
    #[serde(default)]
    pub op: Option<String>,
    /// For assert steps: expected value.
    #[serde(default)]
    pub value: Option<serde_yaml::Value>,
    /// Capture the command output into this variable name.
    #[serde(default)]
    pub capture: Option<String>,
    /// Error handling policy for this step.
    #[serde(default)]
    pub on_error: OnError,
    /// Optional loop specification.
    #[serde(rename = "loop", default)]
    pub loop_spec: Option<LoopSpec>,
}

/// What to do when a step fails.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum OnError {
    #[default]
    Fail,
    Skip,
    Continue,
}

/// Loop specification for repeating a step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopSpec {
    pub var: String,
    pub from: usize,
    pub to: usize,
}

// ── Report structs ──────────────────────────────────────────────────────────

/// Summary of a playbook execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybookReport {
    pub name: String,
    pub total_steps: usize,
    pub passed: usize,
    pub failed: usize,
    pub skipped: usize,
    pub duration: Duration,
    pub step_results: Vec<StepResult>,
}

impl PlaybookReport {
    /// True if all steps passed (no failures).
    pub fn all_passed(&self) -> bool {
        self.failed == 0
    }
}

impl std::fmt::Display for PlaybookReport {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        writeln!(f, "Playbook: {}", self.name)?;
        writeln!(
            f,
            "Result:   {} passed, {} failed, {} skipped ({} total)",
            self.passed, self.failed, self.skipped, self.total_steps,
        )?;
        writeln!(f, "Duration: {:.2}s", self.duration.as_secs_f64())?;
        for sr in &self.step_results {
            let status = if sr.success { "OK" } else { "FAIL" };
            write!(
                f,
                "  [{}/{}] {} ... {} ({:.1}ms)",
                sr.step_index + 1,
                self.total_steps,
                sr.command,
                status,
                sr.duration.as_secs_f64() * 1000.0,
            )?;
            if let Some(ref e) = sr.error {
                write!(f, " — {e}")?;
            }
            writeln!(f)?;
        }
        Ok(())
    }
}

/// Result of a single step execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
    pub step_index: usize,
    pub command: String,
    pub success: bool,
    pub duration: Duration,
    pub output: Option<String>,
    pub error: Option<String>,
}

// ── PlaybookRunner ──────────────────────────────────────────────────────────

/// Executes a parsed `Playbook` step-by-step.
pub struct PlaybookRunner {
    playbook: Playbook,
    vars: HashMap<String, String>,
}

impl PlaybookRunner {
    /// Load a playbook from a YAML file.
    pub fn from_file(path: &Path) -> anyhow::Result<Self> {
        let content =
            std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
        Self::from_str(&content)
    }

    /// Parse a playbook from a YAML string.
    pub fn from_str(yaml: &str) -> anyhow::Result<Self> {
        let playbook: Playbook =
            serde_yaml::from_str(yaml).context("parse playbook YAML")?;
        let vars = playbook.variables.clone();
        Ok(Self { playbook, vars })
    }

    /// Override or add variables before execution.
    pub fn set_var(&mut self, name: impl Into<String>, value: impl Into<String>) {
        self.vars.insert(name.into(), value.into());
    }

    /// Execute all steps, returning a report.
    pub async fn run(
        &mut self,
        session: &mut SessionState,
        client: &node_service::Client,
    ) -> PlaybookReport {
        let start = Instant::now();
        let total = self.expanded_step_count();
        let mut results = Vec::new();
        let mut passed = 0usize;
        let mut failed = 0usize;
        let mut skipped = 0usize;
        let mut step_idx = 0usize;
        let mut abort = false;

        for step in &self.playbook.steps.clone() {
            if abort {
                skipped += 1;
                results.push(StepResult {
                    step_index: step_idx,
                    command: step.command.clone(),
                    success: false,
                    duration: Duration::ZERO,
                    output: None,
                    error: Some("skipped (prior failure)".into()),
                });
                step_idx += 1;
                continue;
            }

            if let Some(ref ls) = step.loop_spec {
                for i in ls.from..=ls.to {
                    self.vars.insert(ls.var.clone(), i.to_string());
                    let sr = self.execute_step(step, step_idx, total, session, client).await;
                    if sr.success {
                        passed += 1;
                    } else {
                        failed += 1;
                        if step.on_error == OnError::Fail {
                            abort = true;
                        }
                    }
                    results.push(sr);
                    step_idx += 1;
                    if abort {
                        break;
                    }
                }
            } else {
                let sr = self.execute_step(step, step_idx, total, session, client).await;
                if sr.success {
                    passed += 1;
                } else {
                    match step.on_error {
                        OnError::Fail => {
                            failed += 1;
                            abort = true;
                        }
                        OnError::Skip => skipped += 1,
                        OnError::Continue => failed += 1,
                    }
                }
                results.push(sr);
                step_idx += 1;
            }
        }

        PlaybookReport {
            name: self.playbook.name.clone(),
            total_steps: step_idx,
            passed,
            failed,
            skipped,
            duration: start.elapsed(),
            step_results: results,
        }
    }

    /// Execute a single step.
    async fn execute_step(
        &mut self,
        step: &PlaybookStep,
        index: usize,
        total: usize,
        session: &mut SessionState,
        client: &node_service::Client,
    ) -> StepResult {
        let t = Instant::now();
        let cmd = match self.step_to_command(step) {
            Ok(c) => c,
            Err(e) => {
                return StepResult {
                    step_index: index,
                    command: step.command.clone(),
                    success: false,
                    duration: t.elapsed(),
                    output: None,
                    error: Some(format!("{e:#}")),
                };
            }
        };

        eprintln!(
            "[{}/{}] {} ...",
            index + 1,
            total,
            step.command,
        );

        let cr = CommandRegistry::execute(&cmd, session, client).await;

        // Capture output into variable if requested.
        if let Some(ref var_name) = step.capture {
            if let Some(ref out) = cr.output {
                self.vars.insert(var_name.clone(), out.clone());
            }
            for (k, v) in &cr.data {
                self.vars.insert(format!("{var_name}.{k}"), v.clone());
            }
        }

        StepResult {
            step_index: index,
            command: step.command.clone(),
            success: cr.success,
            duration: t.elapsed(),
            output: cr.output,
            error: cr.error,
        }
    }

    /// Convert a YAML step into a typed `Command`.
    fn step_to_command(&self, step: &PlaybookStep) -> anyhow::Result<Command> {
        let cmd_name = step.command.as_str();
        match cmd_name {
            // ── Lifecycle commands ────────────────────────────────────────
            "connect" => Ok(Command::Connect {
                server: self.resolve_str(&step.args, "server")?,
                ca_cert: self.opt_str(&step.args, "ca_cert"),
                insecure: self.opt_bool(&step.args, "insecure"),
            }),
            "login" => Ok(Command::Login {
                username: self.resolve_str(&step.args, "username")?,
                password: self.resolve_str(&step.args, "password")?,
            }),
            "register" => Ok(Command::Register {
                username: self.resolve_str(&step.args, "username")?,
                password: self.resolve_str(&step.args, "password")?,
            }),
            "send" | "send-message" => Ok(Command::SendMessage {
                text: self.resolve_str(&step.args, "text")?,
            }),
            "wait" => Ok(Command::Wait {
                duration_ms: self.resolve_u64(&step.args, "duration_ms")?,
            }),
            "set-var" | "setvar" => Ok(Command::SetVar {
                name: self.resolve_str(&step.args, "name")?,
                value: self.resolve_str(&step.args, "value")?,
            }),
            "assert" => {
                let condition = self.build_assert_condition(step)?;
                Ok(Command::Assert { condition })
            }

            // ── Session / identity ───────────────────────────────────────
            "help" => Ok(Command::Help),
            "quit" | "exit" => Ok(Command::Quit),
            "whoami" => Ok(Command::Whoami),
            "list" | "ls" => Ok(Command::List),
            "switch" | "sw" => Ok(Command::Switch {
                target: self.resolve_str(&step.args, "target")?,
            }),
            "dm" => Ok(Command::Dm {
                username: self.resolve_str(&step.args, "username")?,
            }),
            "create-group" | "cg" => Ok(Command::CreateGroup {
                name: self.resolve_str(&step.args, "name")?,
            }),
            "invite" => Ok(Command::Invite {
                target: self.resolve_str(&step.args, "target")?,
            }),
            "remove" | "kick" => Ok(Command::Remove {
                target: self.resolve_str(&step.args, "target")?,
            }),
            "leave" => Ok(Command::Leave),
            "join" => Ok(Command::Join),
            "members" => Ok(Command::Members),
            "group-info" | "gi" => Ok(Command::GroupInfo),
            "rename" => Ok(Command::Rename {
                name: self.resolve_str(&step.args, "name")?,
            }),
            "history" | "hist" => Ok(Command::History {
                count: self.opt_usize(&step.args, "count").unwrap_or(20),
            }),

            // ── Security / crypto ────────────────────────────────────────
            "verify" => Ok(Command::Verify {
                username: self.resolve_str(&step.args, "username")?,
            }),
            "update-key" | "rotate-key" => Ok(Command::UpdateKey),
            "typing" => Ok(Command::Typing),
            "typing-notify" => Ok(Command::TypingNotify {
                enabled: self.opt_bool(&step.args, "enabled"),
            }),
            "react" => Ok(Command::React {
                emoji: self.resolve_str(&step.args, "emoji")?,
                index: self.opt_usize(&step.args, "index"),
            }),
            "edit" => Ok(Command::Edit {
                index: self.resolve_usize(&step.args, "index")?,
                new_text: self.resolve_str(&step.args, "new_text")?,
            }),
            "delete" | "del" => Ok(Command::Delete {
                index: self.resolve_usize(&step.args, "index")?,
            }),
            "send-file" | "sf" => Ok(Command::SendFile {
                path: self.resolve_str(&step.args, "path")?,
            }),
            "download" | "dl" => Ok(Command::Download {
                index: self.resolve_usize(&step.args, "index")?,
            }),
            "delete-account" => Ok(Command::DeleteAccount),
            "disappear" => Ok(Command::Disappear {
                arg: self.opt_str(&step.args, "duration"),
            }),
            "privacy" => Ok(Command::Privacy {
                arg: self.opt_str(&step.args, "setting"),
            }),
            "verify-fs" => Ok(Command::VerifyFs),
            "rotate-all-keys" => Ok(Command::RotateAllKeys),
            "devices" => Ok(Command::Devices),
            "register-device" => Ok(Command::RegisterDevice {
                name: self.resolve_str(&step.args, "name")?,
            }),
            "revoke-device" => Ok(Command::RevokeDevice {
                id_prefix: self.resolve_str(&step.args, "id_prefix")?,
            }),

            // ── Mesh ─────────────────────────────────────────────────────
            "mesh-peers" => Ok(Command::MeshPeers),
            "mesh-server" => Ok(Command::MeshServer {
                addr: self.resolve_str(&step.args, "addr")?,
            }),
            "mesh-send" => Ok(Command::MeshSend {
                peer_id: self.resolve_str(&step.args, "peer_id")?,
                message: self.resolve_str(&step.args, "message")?,
            }),
            "mesh-broadcast" => Ok(Command::MeshBroadcast {
                topic: self.resolve_str(&step.args, "topic")?,
                message: self.resolve_str(&step.args, "message")?,
            }),
            "mesh-subscribe" => Ok(Command::MeshSubscribe {
                topic: self.resolve_str(&step.args, "topic")?,
            }),
            "mesh-route" => Ok(Command::MeshRoute),
            "mesh-identity" | "mesh-id" => Ok(Command::MeshIdentity),
            "mesh-store" => Ok(Command::MeshStore),

            other => bail!("unknown command: {other}"),
        }
    }

    /// Build an `AssertCondition` from a playbook step.
    fn build_assert_condition(&self, step: &PlaybookStep) -> anyhow::Result<AssertCondition> {
        let cond = step
            .condition
            .as_deref()
            .context("assert step requires 'condition' field")?;
        match cond {
            "connected" => Ok(AssertCondition::Connected),
            "logged_in" => Ok(AssertCondition::LoggedIn),
            "in_conversation" => {
                let name = self.resolve_str(&step.args, "name")
                    .or_else(|_| step.value.as_ref()
                        .and_then(|v| v.as_str())
                        .map(|s| self.substitute(s))
                        .context("assert in_conversation requires 'name' arg or 'value'"))?;
                Ok(AssertCondition::InConversation { name })
            }
            "message_count" => {
                let op = self.parse_cmp_op(step.op.as_deref().unwrap_or("gte"))?;
                let count = step
                    .value
                    .as_ref()
                    .and_then(|v| v.as_u64())
                    .context("message_count assert requires numeric 'value'")?
                    as usize;
                Ok(AssertCondition::MessageCount { op, count })
            }
            "member_count" => {
                let op = self.parse_cmp_op(step.op.as_deref().unwrap_or("gte"))?;
                let count = step
                    .value
                    .as_ref()
                    .and_then(|v| v.as_u64())
                    .context("member_count assert requires numeric 'value'")?
                    as usize;
                Ok(AssertCondition::MemberCount { op, count })
            }
            other => Ok(AssertCondition::Custom {
                expression: other.to_string(),
            }),
        }
    }

    fn parse_cmp_op(&self, s: &str) -> anyhow::Result<CmpOp> {
        match s {
            "eq" | "==" => Ok(CmpOp::Eq),
            "ne" | "!=" => Ok(CmpOp::Ne),
            "gt" | ">" => Ok(CmpOp::Gt),
            "lt" | "<" => Ok(CmpOp::Lt),
            "gte" | ">=" => Ok(CmpOp::Gte),
            "lte" | "<=" => Ok(CmpOp::Lte),
            other => bail!("unknown comparison operator: {other}"),
        }
    }

    // ── Variable substitution helpers ────────────────────────────────────

    /// Substitute `$varname` and `${VAR:-default}` in a string.
    fn substitute(&self, s: &str) -> String {
        let mut result = String::with_capacity(s.len());
        let mut chars = s.chars().peekable();
        while let Some(c) = chars.next() {
            if c == '$' {
                if chars.peek() == Some(&'{') {
                    chars.next(); // consume '{'
                    let mut key = String::new();
                    let mut default = None;
                    while let Some(&ch) = chars.peek() {
                        if ch == '}' {
                            chars.next();
                            break;
                        }
                        if ch == ':' && chars.clone().nth(1) == Some('-') {
                            chars.next(); // consume ':'
                            chars.next(); // consume '-'
                            let mut def = String::new();
                            while let Some(&dch) = chars.peek() {
                                if dch == '}' {
                                    chars.next();
                                    break;
                                }
                                def.push(dch);
                                chars.next();
                            }
                            default = Some(def);
                            break;
                        }
                        key.push(ch);
                        chars.next();
                    }
                    if let Some(val) = self.vars.get(&key) {
                        result.push_str(val);
                    } else if let Ok(val) = std::env::var(&key) {
                        result.push_str(&val);
                    } else if let Some(def) = default {
                        result.push_str(&def);
                    }
                } else {
                    let mut key = String::new();
                    while let Some(&ch) = chars.peek() {
                        if ch.is_alphanumeric() || ch == '_' {
                            key.push(ch);
                            chars.next();
                        } else {
                            break;
                        }
                    }
                    if let Some(val) = self.vars.get(&key) {
                        result.push_str(val);
                    } else {
                        result.push('$');
                        result.push_str(&key);
                    }
                }
            } else {
                result.push(c);
            }
        }
        result
    }

    /// Resolve a required string argument with variable substitution.
    fn resolve_str(
        &self,
        args: &HashMap<String, serde_yaml::Value>,
        key: &str,
    ) -> anyhow::Result<String> {
        let val = args
            .get(key)
            .with_context(|| format!("missing required argument: {key}"))?;
        match val {
            serde_yaml::Value::String(s) => Ok(self.substitute(s)),
            serde_yaml::Value::Number(n) => Ok(n.to_string()),
            serde_yaml::Value::Bool(b) => Ok(b.to_string()),
            other => Ok(format!("{other:?}")),
        }
    }

    /// Resolve an optional string argument.
    fn opt_str(
        &self,
        args: &HashMap<String, serde_yaml::Value>,
        key: &str,
    ) -> Option<String> {
        args.get(key).map(|v| match v {
            serde_yaml::Value::String(s) => self.substitute(s),
            serde_yaml::Value::Number(n) => n.to_string(),
            serde_yaml::Value::Bool(b) => b.to_string(),
            other => format!("{other:?}"),
        })
    }

    /// Resolve an optional bool argument (defaults to false).
    fn opt_bool(
        &self,
        args: &HashMap<String, serde_yaml::Value>,
        key: &str,
    ) -> bool {
        args.get(key)
            .and_then(|v| v.as_bool())
            .unwrap_or(false)
    }

    /// Resolve a required usize argument.
    fn resolve_usize(
        &self,
        args: &HashMap<String, serde_yaml::Value>,
        key: &str,
    ) -> anyhow::Result<usize> {
        let val = args
            .get(key)
            .with_context(|| format!("missing required argument: {key}"))?;
        val.as_u64()
            .map(|n| n as usize)
            .with_context(|| format!("argument '{key}' must be a positive integer"))
    }

    /// Resolve a required u64 argument.
    fn resolve_u64(
        &self,
        args: &HashMap<String, serde_yaml::Value>,
        key: &str,
    ) -> anyhow::Result<u64> {
        let val = args
            .get(key)
            .with_context(|| format!("missing required argument: {key}"))?;
        val.as_u64()
            .with_context(|| format!("argument '{key}' must be a positive integer"))
    }

    /// Resolve an optional usize argument.
    fn opt_usize(
        &self,
        args: &HashMap<String, serde_yaml::Value>,
        key: &str,
    ) -> Option<usize> {
        args.get(key).and_then(|v| v.as_u64()).map(|n| n as usize)
    }

    /// Count total expanded steps (including loop iterations).
    fn expanded_step_count(&self) -> usize {
        self.playbook
            .steps
            .iter()
            .map(|s| {
                if let Some(ref ls) = s.loop_spec {
                    if ls.to >= ls.from {
                        ls.to - ls.from + 1
                    } else {
                        0
                    }
                } else {
                    1
                }
            })
            .sum()
    }
}

// ── Tests ───────────────────────────────────────────────────────────────────

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

    #[test]
    fn parse_minimal_playbook() {
        let yaml = r#"
name: "test"
steps:
  - command: whoami
  - command: list
"#;
        let runner = PlaybookRunner::from_str(yaml).unwrap();
        assert_eq!(runner.playbook.name, "test");
        assert_eq!(runner.playbook.steps.len(), 2);
        assert_eq!(runner.playbook.steps[0].command, "whoami");
    }

    #[test]
    fn parse_playbook_with_variables() {
        let yaml = r#"
name: "var test"
variables:
  user: alice
  server: "127.0.0.1:5001"
steps:
  - command: dm
    args:
      username: "$user"
"#;
        let runner = PlaybookRunner::from_str(yaml).unwrap();
        assert_eq!(runner.vars["user"], "alice");
        assert_eq!(runner.vars["server"], "127.0.0.1:5001");
    }

    #[test]
    fn variable_substitution() {
        let mut vars = HashMap::new();
        vars.insert("name".to_string(), "alice".to_string());
        vars.insert("port".to_string(), "5001".to_string());
        let runner = PlaybookRunner {
            playbook: Playbook {
                name: "test".into(),
                description: None,
                variables: HashMap::new(),
                steps: vec![],
            },
            vars,
        };
        assert_eq!(runner.substitute("hello $name"), "hello alice");
        assert_eq!(runner.substitute("port=$port!"), "port=5001!");
        assert_eq!(runner.substitute("${name}@server"), "alice@server");
        assert_eq!(
            runner.substitute("${missing:-default}"),
            "default"
        );
        assert_eq!(runner.substitute("no vars here"), "no vars here");
    }

    #[test]
    fn step_to_command_mapping() {
        let yaml = r#"
name: "mapping test"
variables:
  user: bob
steps:
  - command: dm
    args:
      username: "$user"
  - command: send
    args:
      text: "hello"
  - command: history
    args:
      count: 10
  - command: wait
    args:
      duration_ms: 500
"#;
        let runner = PlaybookRunner::from_str(yaml).unwrap();
        let cmd0 = runner.step_to_command(&runner.playbook.steps[0]).unwrap();
        assert!(matches!(cmd0, Command::Dm { username } if username == "bob"));

        let cmd1 = runner.step_to_command(&runner.playbook.steps[1]).unwrap();
        assert!(matches!(cmd1, Command::SendMessage { text } if text == "hello"));

        let cmd2 = runner.step_to_command(&runner.playbook.steps[2]).unwrap();
        assert!(matches!(cmd2, Command::History { count: 10 }));

        let cmd3 = runner.step_to_command(&runner.playbook.steps[3]).unwrap();
        assert!(matches!(cmd3, Command::Wait { duration_ms: 500 }));
    }

    #[test]
    fn parse_assert_step() {
        let yaml = r#"
name: "assert test"
steps:
  - command: assert
    condition: message_count
    op: gte
    value: 5
"#;
        let runner = PlaybookRunner::from_str(yaml).unwrap();
        let cmd = runner.step_to_command(&runner.playbook.steps[0]).unwrap();
        match cmd {
            Command::Assert {
                condition: AssertCondition::MessageCount { op, count },
            } => {
                assert_eq!(op, CmpOp::Gte);
                assert_eq!(count, 5);
            }
            other => panic!("expected Assert MessageCount, got {other:?}"),
        }
    }

    #[test]
    fn parse_loop_spec() {
        let yaml = r#"
name: "loop test"
steps:
  - command: send
    args:
      text: "msg $i"
    loop:
      var: i
      from: 1
      to: 5
"#;
        let runner = PlaybookRunner::from_str(yaml).unwrap();
        assert_eq!(runner.expanded_step_count(), 5);
        let ls = runner.playbook.steps[0].loop_spec.as_ref().unwrap();
        assert_eq!(ls.var, "i");
        assert_eq!(ls.from, 1);
        assert_eq!(ls.to, 5);
    }

    #[test]
    fn on_error_defaults_to_fail() {
        let yaml = r#"
name: "error test"
steps:
  - command: whoami
  - command: list
    on_error: continue
  - command: quit
    on_error: skip
"#;
        let runner = PlaybookRunner::from_str(yaml).unwrap();
        assert_eq!(runner.playbook.steps[0].on_error, OnError::Fail);
        assert_eq!(runner.playbook.steps[1].on_error, OnError::Continue);
        assert_eq!(runner.playbook.steps[2].on_error, OnError::Skip);
    }

    #[test]
    fn cmp_op_parsing() {
        let runner = PlaybookRunner::from_str("name: t\nsteps: []").unwrap();
        assert!(matches!(runner.parse_cmp_op("eq"), Ok(CmpOp::Eq)));
        assert!(matches!(runner.parse_cmp_op("=="), Ok(CmpOp::Eq)));
        assert!(matches!(runner.parse_cmp_op("gte"), Ok(CmpOp::Gte)));
        assert!(matches!(runner.parse_cmp_op(">="), Ok(CmpOp::Gte)));
        assert!(matches!(runner.parse_cmp_op("<"), Ok(CmpOp::Lt)));
        assert!(runner.parse_cmp_op("invalid").is_err());
    }

    #[test]
    fn report_display() {
        let report = PlaybookReport {
            name: "test".into(),
            total_steps: 3,
            passed: 2,
            failed: 1,
            skipped: 0,
            duration: Duration::from_millis(150),
            step_results: vec![
                StepResult {
                    step_index: 0,
                    command: "whoami".into(),
                    success: true,
                    duration: Duration::from_millis(10),
                    output: None,
                    error: None,
                },
                StepResult {
                    step_index: 1,
                    command: "dm".into(),
                    success: true,
                    duration: Duration::from_millis(50),
                    output: None,
                    error: None,
                },
                StepResult {
                    step_index: 2,
                    command: "assert".into(),
                    success: false,
                    duration: Duration::from_millis(1),
                    output: None,
                    error: Some("message count 0 < 1".into()),
                },
            ],
        };
        let s = format!("{report}");
        assert!(s.contains("2 passed, 1 failed"));
        assert!(s.contains("[3/3] assert ... FAIL"));
    }
}
