use http::Method;
use serde_json::Value;
use std::{
    collections::HashMap,
    fmt,
    path::PathBuf,
    time::Duration,
};

mod assertion;
mod artifact;
mod executor;
mod planner;
mod response_capture;
mod runner;
mod session;
mod template;

pub use assertion::{Assertion, AssertionMismatch, AssertionMismatchValue};
pub use artifact::{
    ArtifactMetadata, ArtifactTimingPhase, ArtifactTranscript, ArtifactTranscriptDirection,
    ExecutionArtifact, HttpArtifactMetadata, RetainedArtifact,
};
pub use planner::RequestPlanner;
pub use response_capture::{ResponseCapture, ResponseSnapshot};
pub use runner::{
    execute_plan as execute_request_plan,
    execute_plan_with_observer as execute_request_plan_with_observer, ExecutionEvent,
    ExecutionObserver, ExecutionOptions, ExecutionRecord, InterruptSignal, RequestFailure,
    RequestFailureKind,
};
pub use session::{SessionHandle, SessionRegistry};
pub use template::{
    expand_templates, FragmentInclude, GraphqlTemplateOperation, HttpTemplateOperation,
    McpInitializeTemplate, McpTemplateCall, McpTemplateOperation, McpToolCallTemplate,
    RequestTemplate, RequestTemplateOperation, SseTemplateAction, SseTemplateOperation,
    TemplateError, VariableStore, WsSendKindTemplate, WsTemplateAction,
    WsTemplateOperation,
};

#[derive(Debug, Clone)]
pub enum FormDataType {
    Text(String),
    File(PathBuf),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestProtocol {
    Http,
    Graphql,
    Mcp,
    Sse,
    Ws,
}

impl RequestProtocol {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Http => "http",
            Self::Graphql => "graphql",
            Self::Mcp => "mcp",
            Self::Sse => "sse",
            Self::Ws => "ws",
        }
    }
}

pub(crate) fn parse_within_duration(raw: &str) -> Result<Duration, String> {
    let trimmed = raw.trim();

    let parse_number = |value: &str| {
        value.parse::<u64>().map_err(|_| {
            format!(
                "invalid within duration '{}' (expected formats like 250ms, 2s, or 1m)",
                raw
            )
        })
    };

    if let Some(value) = trimmed.strip_suffix("ms") {
        return Ok(Duration::from_millis(parse_number(value.trim())?));
    }

    if let Some(value) = trimmed.strip_suffix('s') {
        return Ok(Duration::from_secs(parse_number(value.trim())?));
    }

    if let Some(value) = trimmed.strip_suffix('m') {
        return Ok(Duration::from_secs(parse_number(value.trim())? * 60));
    }

    Err(format!(
        "invalid within duration '{}' (expected formats like 250ms, 2s, or 1m)",
        raw
    ))
}

#[derive(Debug, Clone)]
pub struct HttpOperation {
    pub method: Method,
    pub url: String,
    pub headers: HashMap<String, String>,
    pub query_params: HashMap<String, String>,
    pub form_data: HashMap<String, FormDataType>,
    pub body: Option<String>,
    pub body_content_type: Option<String>,
}

#[derive(Debug, Clone)]
pub struct GraphqlOperation {
    pub http: HttpOperation,
    pub operation_name: Option<String>,
    pub document: String,
    pub variables_json: Option<String>,
}

#[derive(Debug, Clone)]
pub struct McpInitialize {
    pub protocol_version: Option<String>,
    pub client_name: Option<String>,
    pub client_version: Option<String>,
    pub capabilities_json: Option<String>,
}

#[derive(Debug, Clone)]
pub struct McpToolCall {
    pub tool_name: String,
    pub arguments_json: Option<String>,
}

#[derive(Debug, Clone)]
pub enum McpCall {
    Initialize(McpInitialize),
    ToolsList,
    ResourcesList,
    ToolsCall(McpToolCall),
}

impl McpCall {
    pub fn method_name(&self) -> &'static str {
        match self {
            Self::Initialize(_) => "initialize",
            Self::ToolsList => "tools/list",
            Self::ResourcesList => "resources/list",
            Self::ToolsCall(_) => "tools/call",
        }
    }
}

#[derive(Debug, Clone)]
pub struct McpOperation {
    pub http: HttpOperation,
    pub call: McpCall,
}

#[derive(Debug, Clone)]
pub enum SseAction {
    Open,
    Receive { within: String },
}

impl SseAction {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Open => "open",
            Self::Receive { .. } => "receive",
        }
    }
}

#[derive(Debug, Clone)]
pub struct SseOperation {
    pub http: HttpOperation,
    pub action: SseAction,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WsSendKind {
    Text,
    Json,
}

impl WsSendKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Text => "text",
            Self::Json => "json",
        }
    }
}

#[derive(Debug, Clone)]
pub enum WsAction {
    Open,
    Send { kind: WsSendKind, payload: String },
    Exchange {
        kind: WsSendKind,
        payload: String,
        within: String,
    },
    Receive { within: String },
}

impl WsAction {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Open => "open",
            Self::Send { .. } => "send",
            Self::Exchange { .. } => "exchange",
            Self::Receive { .. } => "receive",
        }
    }
}

#[derive(Debug, Clone)]
pub struct WsOperation {
    pub http: HttpOperation,
    pub action: WsAction,
}

#[derive(Debug, Clone)]
pub enum RequestOperation {
    Http(HttpOperation),
    Graphql(GraphqlOperation),
    Mcp(McpOperation),
    Sse(SseOperation),
    Ws(WsOperation),
}

impl RequestOperation {
    pub fn protocol(&self) -> RequestProtocol {
        match self {
            Self::Http(_) => RequestProtocol::Http,
            Self::Graphql(_) => RequestProtocol::Graphql,
            Self::Mcp(_) => RequestProtocol::Mcp,
            Self::Sse(_) => RequestProtocol::Sse,
            Self::Ws(_) => RequestProtocol::Ws,
        }
    }

    pub fn as_http(&self) -> Option<&HttpOperation> {
        match self {
            Self::Http(operation) => Some(operation),
            Self::Graphql(operation) => Some(&operation.http),
            Self::Mcp(operation) => Some(&operation.http),
            Self::Sse(operation) => Some(&operation.http),
            Self::Ws(operation) => Some(&operation.http),
        }
    }

    pub fn as_http_mut(&mut self) -> Option<&mut HttpOperation> {
        match self {
            Self::Http(operation) => Some(operation),
            Self::Graphql(operation) => Some(&mut operation.http),
            Self::Mcp(operation) => Some(&mut operation.http),
            Self::Sse(operation) => Some(&mut operation.http),
            Self::Ws(operation) => Some(&mut operation.http),
        }
    }

    pub fn as_graphql(&self) -> Option<&GraphqlOperation> {
        match self {
            Self::Graphql(operation) => Some(operation),
            Self::Http(_) | Self::Mcp(_) | Self::Sse(_) | Self::Ws(_) => None,
        }
    }

    pub fn as_mcp(&self) -> Option<&McpOperation> {
        match self {
            Self::Mcp(operation) => Some(operation),
            Self::Http(_) | Self::Graphql(_) | Self::Sse(_) | Self::Ws(_) => None,
        }
    }

    pub fn as_sse(&self) -> Option<&SseOperation> {
        match self {
            Self::Sse(operation) => Some(operation),
            Self::Http(_) | Self::Graphql(_) | Self::Mcp(_) | Self::Ws(_) => None,
        }
    }

    pub fn as_ws(&self) -> Option<&WsOperation> {
        match self {
            Self::Ws(operation) => Some(operation),
            Self::Http(_) | Self::Graphql(_) | Self::Mcp(_) | Self::Sse(_) => None,
        }
    }

    pub fn protocol_context_json(&self) -> Option<Value> {
        match self {
            Self::Http(_) => None,
            Self::Graphql(operation) => operation.protocol_context_json(),
            Self::Mcp(operation) => operation.protocol_context_json(),
            Self::Sse(operation) => operation.protocol_context_json(),
            Self::Ws(operation) => operation.protocol_context_json(),
        }
    }
}

#[derive(Debug, Clone)]
pub struct Request {
    pub description: String,
    pub base_description: String,
    pub operation: RequestOperation,
    pub session_name: Option<String>,
    pub callback_src: Vec<String>,
    pub response_captures: Vec<ResponseCapture>,
    pub assertions: Vec<Assertion>,
    pub declared_dependencies: Vec<String>,
    pub dependencies: Vec<String>,
    pub context: HashMap<String, String>,
    pub working_dir: PathBuf,
    pub map_iteration: Option<MapIteration>,
}

impl GraphqlOperation {
    pub fn protocol_context_json(&self) -> Option<Value> {
        graphql_protocol_context_json(
            self.operation_name.as_deref(),
            self.variables_json.as_deref(),
        )
    }
}

impl McpOperation {
    pub fn protocol_context_json(&self) -> Option<Value> {
        let mut context = serde_json::Map::new();
        context.insert(
            "call".to_string(),
            Value::String(self.call.method_name().to_string()),
        );

        match &self.call {
            McpCall::Initialize(initialize) => {
                if let Some(protocol_version) = &initialize.protocol_version {
                    context.insert(
                        "protocolVersion".to_string(),
                        Value::String(protocol_version.clone()),
                    );
                }

                if let Some(client_name) = &initialize.client_name {
                    context.insert("clientName".to_string(), Value::String(client_name.clone()));
                }

                if let Some(client_version) = &initialize.client_version {
                    context.insert(
                        "clientVersion".to_string(),
                        Value::String(client_version.clone()),
                    );
                }

                if let Some(capabilities_json) = &initialize.capabilities_json {
                    insert_json_or_text_field(
                        &mut context,
                        "capabilities",
                        "capabilitiesText",
                        capabilities_json,
                    );
                }
            }
            McpCall::ToolsList | McpCall::ResourcesList => {}
            McpCall::ToolsCall(tool_call) => {
                context.insert(
                    "tool".to_string(),
                    Value::String(tool_call.tool_name.clone()),
                );
                if let Some(arguments_json) = &tool_call.arguments_json {
                    insert_json_or_text_field(
                        &mut context,
                        "arguments",
                        "argumentsText",
                        arguments_json,
                    );
                }
            }
        }

        Some(Value::Object(context))
    }
}

impl SseOperation {
    pub fn protocol_context_json(&self) -> Option<Value> {
        let mut context = serde_json::Map::new();
        context.insert(
            "action".to_string(),
            Value::String(self.action.as_str().to_string()),
        );

        if let SseAction::Receive { within } = &self.action {
            context.insert("within".to_string(), Value::String(within.clone()));
        }

        Some(Value::Object(context))
    }
}

impl WsOperation {
    pub fn protocol_context_json(&self) -> Option<Value> {
        let mut context = serde_json::Map::new();
        context.insert(
            "action".to_string(),
            Value::String(self.action.as_str().to_string()),
        );

        match &self.action {
            WsAction::Open => {}
            WsAction::Send { kind, .. } => {
                context.insert("kind".to_string(), Value::String(kind.as_str().to_string()));
            }
            WsAction::Exchange { kind, within, .. } => {
                context.insert("kind".to_string(), Value::String(kind.as_str().to_string()));
                context.insert("within".to_string(), Value::String(within.clone()));
            }
            WsAction::Receive { within } => {
                context.insert("within".to_string(), Value::String(within.clone()));
            }
        }

        Some(Value::Object(context))
    }
}

#[derive(Debug, Clone)]
pub struct RequestExecution {
    pub output: String,
    pub export_env: HashMap<String, String>,
    pub artifact: ExecutionArtifact,
    pub assertions: Vec<AssertionOutcome>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct AssertionOutcome {
    pub assertion: String,
    pub status: AssertionStatus,
    pub message: Option<String>,
    pub mismatch: Option<AssertionMismatch>,
}

impl AssertionOutcome {
    pub(crate) fn passed(assertion: impl Into<String>) -> Self {
        Self {
            assertion: assertion.into(),
            status: AssertionStatus::Passed,
            message: None,
            mismatch: None,
        }
    }

    pub(crate) fn failed(
        assertion: impl Into<String>,
        message: impl Into<String>,
        mismatch: Option<AssertionMismatch>,
    ) -> Self {
        Self {
            assertion: assertion.into(),
            status: AssertionStatus::Failed,
            message: Some(message.into()),
            mismatch,
        }
    }

    pub(crate) fn skipped(assertion: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            assertion: assertion.into(),
            status: AssertionStatus::Skipped,
            message: Some(message.into()),
            mismatch: None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssertionStatus {
    Passed,
    Failed,
    Skipped,
}

impl AssertionStatus {
    pub(crate) fn as_str(&self) -> &'static str {
        match self {
            Self::Passed => "passed",
            Self::Failed => "failed",
            Self::Skipped => "skipped",
        }
    }
}

#[derive(Debug, Clone)]
pub struct RequestExecutionError {
    message: String,
    assertions: Vec<AssertionOutcome>,
    artifact: Option<ExecutionArtifact>,
}

impl RequestExecutionError {
    fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            assertions: Vec::new(),
            artifact: None,
        }
    }

    fn with_artifact(message: impl Into<String>, artifact: ExecutionArtifact) -> Self {
        Self {
            message: message.into(),
            assertions: Vec::new(),
            artifact: Some(artifact),
        }
    }

    fn with_assertions_and_artifact(
        message: impl Into<String>,
        assertions: Vec<AssertionOutcome>,
        artifact: ExecutionArtifact,
    ) -> Self {
        Self {
            message: message.into(),
            assertions,
            artifact: Some(artifact),
        }
    }

    pub(crate) fn into_parts(self) -> (String, Vec<AssertionOutcome>, Option<ExecutionArtifact>) {
        (self.message, self.assertions, self.artifact)
    }
}

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

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

fn graphql_protocol_context_json(
    operation_name: Option<&str>,
    variables_json: Option<&str>,
) -> Option<Value> {
    let mut context = serde_json::Map::new();

    if let Some(operation_name) = operation_name {
        context.insert(
            "operationName".to_string(),
            Value::String(operation_name.to_string()),
        );
    }

    if let Some(variables_json) = variables_json {
        match serde_json::from_str::<Value>(variables_json) {
            Ok(value) => {
                context.insert("variables".to_string(), value);
            }
            Err(_) => {
                context.insert(
                    "variablesText".to_string(),
                    Value::String(variables_json.to_string()),
                );
            }
        }
    }

    if context.is_empty() {
        None
    } else {
        Some(Value::Object(context))
    }
}

fn insert_json_or_text_field(
    context: &mut serde_json::Map<String, Value>,
    json_key: &str,
    text_key: &str,
    raw_json: &str,
) {
    match serde_json::from_str::<Value>(raw_json) {
        Ok(value) => {
            context.insert(json_key.to_string(), value);
        }
        Err(_) => {
            context.insert(text_key.to_string(), Value::String(raw_json.to_string()));
        }
    }
}

#[derive(Debug, Clone)]
pub struct MapIteration {
    pub variables: Vec<(String, String)>,
    pub label: String,
}

impl MapIteration {
    pub fn suffix(&self) -> String {
        if self.label.is_empty() {
            String::new()
        } else {
            format!("[{}]", self.label)
        }
    }
}

impl Request {
    pub fn protocol(&self) -> RequestProtocol {
        self.operation.protocol()
    }

    pub fn http_operation(&self) -> &HttpOperation {
        self.operation
            .as_http()
            .expect("request operation is not HTTP")
    }

    pub fn http_operation_mut(&mut self) -> &mut HttpOperation {
        self.operation
            .as_http_mut()
            .expect("request operation is not HTTP")
    }

    pub fn graphql_operation(&self) -> Option<&GraphqlOperation> {
        self.operation.as_graphql()
    }

    pub fn mcp_operation(&self) -> Option<&McpOperation> {
        self.operation.as_mcp()
    }

    pub fn sse_operation(&self) -> Option<&SseOperation> {
        self.operation.as_sse()
    }

    pub fn ws_operation(&self) -> Option<&WsOperation> {
        self.operation.as_ws()
    }

    pub fn protocol_context_json(&self) -> Option<Value> {
        let mut context = match self.operation.protocol_context_json() {
            Some(Value::Object(map)) => map,
            Some(other) => return Some(other),
            None => serde_json::Map::new(),
        };

        if matches!(self.protocol(), RequestProtocol::Mcp | RequestProtocol::Sse | RequestProtocol::Ws) {
            if let Some(session_name) = &self.session_name {
                context.insert("sessionName".to_string(), Value::String(session_name.clone()));
            }
        }

        if context.is_empty() {
            None
        } else {
            Some(Value::Object(context))
        }
    }

    // builds a reqwest request then sends it.
    pub async fn exec(
        &self,
        inherited_context: &HashMap<String, String>,
        dependency_artifacts: &HashMap<String, ExecutionArtifact>,
        sessions: &SessionRegistry,
        observer: Option<&ExecutionObserver>,
    ) -> Result<RequestExecution, RequestExecutionError> {
        executor::execute_request(
            self,
            inherited_context,
            dependency_artifacts,
            observer,
            sessions,
        )
        .await
    }

    #[cfg(test)]
    fn build_reqwest_request(
        &self,
        client: &reqwest::Client,
        context_map: &HashMap<String, String>,
    ) -> Result<reqwest::Request, Box<dyn std::error::Error>> {
        executor::build_reqwest_request(self.http_operation(), client, context_map)
    }

    pub fn as_curl(&self) -> String {
        executor::render_curl(self)
    }
}
fn parse_callback_assignment(
    src: &str,
) -> Result<Option<(String, String)>, CallbackAssignmentError> {
    let trimmed = src.trim();
    let Some(index) = trimmed.find("->") else {
        return Ok(None);
    };

    if index == 0 {
        return Err(CallbackAssignmentError::new(
            "callback assignment is missing a command before '->'",
        ));
    }

    let before_arrow = &trimmed[..index];
    let after_arrow = &trimmed[index + 2..];

    let has_surrounding_whitespace = before_arrow
        .chars()
        .last()
        .map(|c| c.is_whitespace())
        .unwrap_or(false)
        && after_arrow
            .chars()
            .next()
            .map(|c| c.is_whitespace())
            .unwrap_or(false);

    if !has_surrounding_whitespace {
        return Err(CallbackAssignmentError::new(
            "callback assignment requires whitespace on both sides of '->'",
        ));
    }

    let command = before_arrow.trim();
    if command.is_empty() {
        return Err(CallbackAssignmentError::new(
            "callback assignment command cannot be empty",
        ));
    }

    let remainder = after_arrow.trim();
    if !remainder.starts_with('$') {
        return Err(CallbackAssignmentError::new(
            "callback assignment target must start with '$'",
        ));
    }

    let name = remainder.trim_start_matches('$').trim();
    if name.is_empty() {
        return Err(CallbackAssignmentError::new(
            "callback assignment target variable name cannot be empty",
        ));
    }

    Ok(Some((command.to_string(), name.to_string())))
}

fn sanitize_callback_assignment_output(output: &str) -> String {
    output
        .trim_end_matches(['\n', '\r'])
        .replace('\0', "")
        .to_string()
}

#[derive(Debug)]
struct CallbackAssignmentError {
    message: String,
}

impl CallbackAssignmentError {
    fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
        }
    }
}

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

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

#[derive(Debug)]
struct CaptureDependencyError {
    dependency: String,
    request: String,
}

impl std::fmt::Display for CaptureDependencyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "Request '{}' references dependency '{}' in a capture but it has not executed yet.",
            self.request, self.dependency
        )
    }
}

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

fn apply_map_suffix_to_exports(
    export_env: HashMap<String, String>,
    iteration: Option<&MapIteration>,
) -> HashMap<String, String> {
    let Some(iteration) = iteration else {
        return export_env;
    };

    let suffix = iteration.suffix();
    if suffix.is_empty() {
        return export_env;
    }

    let reserved = ["RESPONSE", "STATUS", "STATUS_TEXT", "DESCRIPTION"];
    let mut transformed: HashMap<String, String> = HashMap::with_capacity(export_env.len());

    for (key, value) in export_env.into_iter() {
        if reserved.iter().any(|reserved_key| reserved_key == &key) {
            transformed.insert(key, value);
        } else {
            transformed.insert(format!("{}{}", key, suffix), value);
        }
    }

    transformed
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{collections::HashMap, path::PathBuf};

    fn base_request() -> Request {
        Request {
            description: "Test Request".into(),
            base_description: "Test Request".into(),
            operation: RequestOperation::Http(HttpOperation {
                method: Method::POST,
                url: "https://example.com".into(),
                headers: HashMap::new(),
                query_params: HashMap::new(),
                form_data: HashMap::new(),
                body: None,
                body_content_type: None,
            }),
            session_name: None,
            callback_src: vec![],
            response_captures: vec![],
            assertions: vec![],
            declared_dependencies: vec![],
            dependencies: vec![],
            context: HashMap::new(),
            working_dir: PathBuf::new(),
            map_iteration: None,
        }
    }

    fn base_graphql_request() -> Request {
        Request {
            description: "GraphQL Request".into(),
            base_description: "GraphQL Request".into(),
            operation: RequestOperation::Graphql(GraphqlOperation {
                http: HttpOperation {
                    method: Method::POST,
                    url: "https://example.com/graphql".into(),
                    headers: HashMap::new(),
                    query_params: HashMap::new(),
                    form_data: HashMap::new(),
                    body: None,
                    body_content_type: None,
                },
                operation_name: Some("GetUser".into()),
                document: "query GetUser($id: ID!) { user(id: $id) { id } }".into(),
                variables_json: Some(r#"{"id":"123"}"#.into()),
            }),
            session_name: None,
            callback_src: vec![],
            response_captures: vec![],
            assertions: vec![],
            declared_dependencies: vec![],
            dependencies: vec![],
            context: HashMap::new(),
            working_dir: PathBuf::new(),
            map_iteration: None,
        }
    }

    fn base_mcp_request() -> Request {
        Request {
            description: "MCP Request".into(),
            base_description: "MCP Request".into(),
            operation: RequestOperation::Mcp(McpOperation {
                http: HttpOperation {
                    method: Method::POST,
                    url: "https://example.com/mcp".into(),
                    headers: HashMap::new(),
                    query_params: HashMap::new(),
                    form_data: HashMap::new(),
                    body: None,
                    body_content_type: None,
                },
                call: McpCall::Initialize(McpInitialize {
                    protocol_version: None,
                    client_name: None,
                    client_version: None,
                    capabilities_json: None,
                }),
            }),
            session_name: Some("app".into()),
            callback_src: vec![],
            response_captures: vec![],
            assertions: vec![],
            declared_dependencies: vec![],
            dependencies: vec![],
            context: HashMap::new(),
            working_dir: PathBuf::new(),
            map_iteration: None,
        }
    }

    fn base_sse_request() -> Request {
        Request {
            description: "SSE Request".into(),
            base_description: "SSE Request".into(),
            operation: RequestOperation::Sse(SseOperation {
                http: HttpOperation {
                    method: Method::GET,
                    url: "https://example.com/stream".into(),
                    headers: HashMap::new(),
                    query_params: HashMap::new(),
                    form_data: HashMap::new(),
                    body: None,
                    body_content_type: None,
                },
                action: SseAction::Receive {
                    within: "5s".into(),
                },
            }),
            session_name: Some("prices".into()),
            callback_src: vec![],
            response_captures: vec![],
            assertions: vec![],
            declared_dependencies: vec![],
            dependencies: vec![],
            context: HashMap::new(),
            working_dir: PathBuf::new(),
            map_iteration: None,
        }
    }

    fn base_ws_request() -> Request {
        Request {
            description: "WS Request".into(),
            base_description: "WS Request".into(),
            operation: RequestOperation::Ws(WsOperation {
                http: HttpOperation {
                    method: Method::GET,
                    url: "wss://example.com/ws".into(),
                    headers: HashMap::new(),
                    query_params: HashMap::new(),
                    form_data: HashMap::new(),
                    body: None,
                    body_content_type: None,
                },
                action: WsAction::Send {
                    kind: WsSendKind::Json,
                    payload: r#"{"type":"ping"}"#.into(),
                },
            }),
            session_name: Some("chat".into()),
            callback_src: vec![],
            response_captures: vec![],
            assertions: vec![],
            declared_dependencies: vec![],
            dependencies: vec![],
            context: HashMap::new(),
            working_dir: PathBuf::new(),
            map_iteration: None,
        }
    }

    #[test]
    fn callback_assignment_parses_with_whitespace() {
        let (command, variable) = parse_callback_assignment("echo foo -> $BAR")
            .unwrap()
            .expect("expected assignment");

        assert_eq!(command, "echo foo");
        assert_eq!(variable, "BAR");
    }

    #[test]
    fn callback_assignment_requires_whitespace() {
        let err = parse_callback_assignment("echo foo->$BAR").unwrap_err();
        assert!(err.to_string().contains("whitespace on both sides of '->'"));
    }

    #[test]
    fn callback_assignment_requires_dollar_prefix() {
        let err = parse_callback_assignment("echo foo -> BAR").unwrap_err();
        assert!(err
            .to_string()
            .contains("callback assignment target must start with '$'"));
    }

    #[test]
    fn build_request_allows_manual_content_type_override_for_multipart() {
        let mut request = base_request();
        request
            .http_operation_mut()
            .headers
            .insert("Content-Type".into(), "application/octet-stream".into());
        request
            .http_operation_mut()
            .form_data
            .insert("name".into(), FormDataType::Text("hen".into()));

        let client = reqwest::Client::new();
        let built = request
            .build_reqwest_request(&client, &HashMap::new())
            .expect("request should build");

        let content_type = built
            .headers()
            .get(reqwest::header::CONTENT_TYPE)
            .expect("content-type header should exist")
            .to_str()
            .expect("content-type should be valid utf-8");

        assert_eq!(content_type, "application/octet-stream");
        assert_eq!(
            built
                .headers()
                .get_all(reqwest::header::CONTENT_TYPE)
                .iter()
                .count(),
            1
        );
    }

    #[test]
    fn build_request_uses_curl_style_multipart_boundary_without_override() {
        let mut request = base_request();
        request
            .http_operation_mut()
            .form_data
            .insert("name".into(), FormDataType::Text("hen".into()));

        let client = reqwest::Client::new();
        let built = request
            .build_reqwest_request(&client, &HashMap::new())
            .expect("request should build");

        let content_type = built
            .headers()
            .get(reqwest::header::CONTENT_TYPE)
            .expect("content-type header should exist")
            .to_str()
            .expect("content-type should be valid utf-8");

        let boundary = content_type
            .strip_prefix("multipart/form-data; boundary=")
            .expect("multipart boundary should be present");
        let body = built
            .body()
            .and_then(|body| body.as_bytes())
            .expect("multipart body should be buffered");
        let body = String::from_utf8(body.to_vec()).expect("text-only multipart should be utf-8");

        assert!(boundary.starts_with("------------------------"));
        assert_eq!(boundary.len(), 56);
        assert!(body.starts_with(&format!("--{}\r\n", boundary)));
        assert!(body.contains("Content-Disposition: form-data; name=\"name\"\r\n\r\nhen\r\n"));
        assert!(body.ends_with(&format!("--{}--\r\n", boundary)));
    }

    #[test]
    fn as_curl_omits_auto_content_type_when_manual_override_exists() {
        let mut request = base_request();
        request.http_operation_mut().body = Some("{}".into());
        request.http_operation_mut().body_content_type = Some("application/json".into());
        request
            .http_operation_mut()
            .headers
            .insert("Content-Type".into(), "application/octet-stream".into());

        let curl = request.as_curl();

        assert!(curl.contains("-H 'Content-Type: application/octet-stream'"));
        assert!(!curl.contains("-H 'Content-Type: application/json'"));
    }

    #[test]
    fn build_request_returns_invalid_header_name_error() {
        let mut request = base_request();
        request
            .http_operation_mut()
            .headers
            .insert("bad header".into(), "value".into());

        let client = reqwest::Client::new();
        let err = request
            .build_reqwest_request(&client, &HashMap::new())
            .expect_err("request should fail");

        assert!(err.to_string().contains("Invalid header name"));
    }

    #[test]
    fn request_protocol_defaults_to_http() {
        let request = base_request();
        assert_eq!(request.protocol(), RequestProtocol::Http);
        assert_eq!(request.protocol().as_str(), "http");
    }

    #[test]
    fn graphql_requests_report_graphql_protocol() {
        let request = base_graphql_request();
        assert_eq!(request.protocol(), RequestProtocol::Graphql);
        assert_eq!(request.protocol().as_str(), "graphql");
        assert_eq!(
            request
                .graphql_operation()
                .expect("graphql operation should exist")
                .operation_name
                .as_deref(),
            Some("GetUser")
        );
    }

    #[test]
    fn graphql_as_curl_serializes_http_json_payload() {
        let request = base_graphql_request();

        let curl = request.as_curl();

        assert!(curl.contains("https://example.com/graphql"));
        assert!(curl.contains("Content-Type: application/json"));
        assert!(curl.contains("application/graphql-response+json, application/json"));
        assert!(curl.contains("operationName"));
        assert!(curl.contains("GetUser"));
        assert!(curl.contains("variables"));
        assert!(curl.contains("query GetUser($id: ID!)"));
    }

    #[test]
    fn mcp_requests_report_mcp_protocol() {
        let request = base_mcp_request();

        assert_eq!(request.protocol(), RequestProtocol::Mcp);
        assert_eq!(request.protocol().as_str(), "mcp");
        assert_eq!(
            request
                .mcp_operation()
                .expect("mcp operation should exist")
                .call
                .method_name(),
            "initialize"
        );
        assert_eq!(
            request.protocol_context_json().expect("protocol context should exist")["sessionName"],
            "app"
        );
    }

    #[test]
    fn mcp_as_curl_serializes_jsonrpc_payload() {
        let request = base_mcp_request();

        let curl = request.as_curl();

        assert!(curl.contains("https://example.com/mcp"));
        assert!(curl.contains("Accept: application/json, text/event-stream"));
        assert!(curl.contains("Content-Type: application/json"));
        assert!(curl.contains("\"method\":\"initialize\""));
        assert!(curl.contains("\"protocolVersion\":\"2025-11-25\""));
        assert!(curl.contains("\"name\":\"hen\""));
        assert!(curl.contains(env!("CARGO_PKG_VERSION")));
    }

    #[test]
    fn sse_requests_report_sse_protocol() {
        let request = base_sse_request();

        assert_eq!(request.protocol(), RequestProtocol::Sse);
        assert_eq!(request.protocol().as_str(), "sse");
        let sse = request
            .sse_operation()
            .expect("sse operation should exist");
        assert!(matches!(sse.action, SseAction::Receive { .. }));
        assert_eq!(
            request.protocol_context_json().expect("protocol context should exist")
                ["sessionName"],
            "prices"
        );
        assert_eq!(
            request.protocol_context_json().expect("protocol context should exist")["action"],
            "receive"
        );
    }

    #[test]
    fn ws_requests_report_ws_protocol() {
        let request = base_ws_request();

        assert_eq!(request.protocol(), RequestProtocol::Ws);
        assert_eq!(request.protocol().as_str(), "ws");
        let ws = request.ws_operation().expect("ws operation should exist");
        assert!(matches!(ws.action, WsAction::Send { .. }));
        assert_eq!(
            request.protocol_context_json().expect("protocol context should exist")["sessionName"],
            "chat"
        );
        assert_eq!(
            request.protocol_context_json().expect("protocol context should exist")["action"],
            "send"
        );
        assert_eq!(
            request.protocol_context_json().expect("protocol context should exist")["kind"],
            "json"
        );
    }

    #[test]
    fn ws_exchange_requests_report_ws_protocol() {
        let mut request = base_ws_request();
        request.operation = RequestOperation::Ws(WsOperation {
            http: request.http_operation().clone(),
            action: WsAction::Exchange {
                kind: WsSendKind::Text,
                payload: "hello".into(),
                within: "1s".into(),
            },
        });

        assert_eq!(request.protocol_context_json().expect("protocol context should exist")["action"], "exchange");
        assert_eq!(request.protocol_context_json().expect("protocol context should exist")["kind"], "text");
        assert_eq!(request.protocol_context_json().expect("protocol context should exist")["within"], "1s");
    }
}
