//! API request and response types.
//!
//! This module contains all the types used for communicating with the Synth API.

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};

// =============================================================================
// Job Status Enums
// =============================================================================

/// Status for policy optimization jobs (GEPA, MIPRO).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyJobStatus {
    Pending,
    Queued,
    Running,
    Paused,
    Succeeded,
    Failed,
    Cancelled,
}

impl PolicyJobStatus {
    /// Check if this status is terminal (job won't change state).
    pub fn is_terminal(&self) -> bool {
        matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
    }

    /// Check if this status indicates success.
    pub fn is_success(&self) -> bool {
        matches!(self, Self::Succeeded)
    }

    /// Parse from string (case-insensitive).
    pub fn from_str(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "pending" => Some(Self::Pending),
            "queued" => Some(Self::Queued),
            "running" => Some(Self::Running),
            "paused" => Some(Self::Paused),
            "succeeded" => Some(Self::Succeeded),
            "failed" => Some(Self::Failed),
            "cancelled" | "canceled" => Some(Self::Cancelled),
            _ => None,
        }
    }

    /// Convert to string representation.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Queued => "queued",
            Self::Running => "running",
            Self::Paused => "paused",
            Self::Succeeded => "succeeded",
            Self::Failed => "failed",
            Self::Cancelled => "cancelled",
        }
    }
}

/// Status for evaluation jobs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvalJobStatus {
    Pending,
    Running,
    Succeeded,
    Failed,
    Cancelled,
}

impl EvalJobStatus {
    pub fn is_terminal(&self) -> bool {
        matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
    }

    pub fn is_success(&self) -> bool {
        matches!(self, Self::Succeeded)
    }

    pub fn from_str(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "pending" => Some(Self::Pending),
            "running" => Some(Self::Running),
            "succeeded" | "completed" => Some(Self::Succeeded),
            "failed" => Some(Self::Failed),
            "cancelled" | "canceled" => Some(Self::Cancelled),
            _ => None,
        }
    }

    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Running => "running",
            Self::Succeeded => "succeeded",
            Self::Failed => "failed",
            Self::Cancelled => "cancelled",
        }
    }
}

// =============================================================================
// Policy Configuration
// =============================================================================

/// Policy model configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyConfig {
    /// Model name (e.g., "gpt-4o-mini", "claude-3-5-sonnet").
    pub model: String,
    /// Provider name (e.g., "openai", "anthropic").
    pub provider: String,
    /// Optional temperature setting.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub temperature: Option<f64>,
    /// Optional max tokens.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_tokens: Option<i32>,
    /// Additional policy settings (agent, timeout, structured_config, etc).
    #[serde(flatten)]
    pub extra: BTreeMap<String, Value>,
}

impl Default for PolicyConfig {
    fn default() -> Self {
        Self {
            model: "gpt-4o-mini".to_string(),
            provider: "openai".to_string(),
            temperature: None,
            max_tokens: None,
            extra: BTreeMap::new(),
        }
    }
}

// =============================================================================
// GEPA Configuration
// =============================================================================

/// GEPA algorithm configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GepaConfig {
    /// Number of generations to run.
    #[serde(default = "default_generations")]
    pub generations: i32,
    /// Population size per generation.
    #[serde(default = "default_population_size")]
    pub population_size: i32,
    /// Mutation rate (0.0 to 1.0).
    #[serde(default = "default_mutation_rate")]
    pub mutation_rate: f64,
    /// Crossover rate (0.0 to 1.0).
    #[serde(default = "default_crossover_rate")]
    pub crossover_rate: f64,
    /// Number of elite candidates to preserve.
    #[serde(default = "default_elite_count")]
    pub elite_count: i32,
    /// Training seeds.
    #[serde(default)]
    pub train_seeds: Vec<i64>,
    /// Validation seeds.
    #[serde(default)]
    pub validation_seeds: Vec<i64>,
}

fn default_generations() -> i32 {
    3
}
fn default_population_size() -> i32 {
    10
}
fn default_mutation_rate() -> f64 {
    0.1
}
fn default_crossover_rate() -> f64 {
    0.5
}
fn default_elite_count() -> i32 {
    2
}

impl Default for GepaConfig {
    fn default() -> Self {
        Self {
            generations: default_generations(),
            population_size: default_population_size(),
            mutation_rate: default_mutation_rate(),
            crossover_rate: default_crossover_rate(),
            elite_count: default_elite_count(),
            train_seeds: vec![],
            validation_seeds: vec![],
        }
    }
}

// =============================================================================
// MIPRO Configuration
// =============================================================================

/// MIPRO algorithm configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MiproConfig {
    /// Number of iterations.
    #[serde(default = "default_mipro_iterations")]
    pub iterations: i32,
    /// Number of candidates per iteration.
    #[serde(default = "default_mipro_candidates")]
    pub num_candidates: i32,
    /// Training seeds.
    #[serde(default)]
    pub train_seeds: Vec<i64>,
    /// Validation seeds.
    #[serde(default)]
    pub validation_seeds: Vec<i64>,
}

fn default_mipro_iterations() -> i32 {
    5
}
fn default_mipro_candidates() -> i32 {
    10
}

impl Default for MiproConfig {
    fn default() -> Self {
        Self {
            iterations: default_mipro_iterations(),
            num_candidates: default_mipro_candidates(),
            train_seeds: vec![],
            validation_seeds: vec![],
        }
    }
}

// =============================================================================
// Job Submit Requests
// =============================================================================

/// Request to submit a GEPA optimization job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GepaJobRequest {
    /// Algorithm name (always "gepa").
    #[serde(default = "default_gepa_algorithm")]
    pub algorithm: String,
    /// Container URL (where the container is running).
    pub container_url: String,
    /// Optional SynthTunnel worker token (sent via header, not body).
    #[serde(default, skip_serializing)]
    pub container_worker_token: Option<String>,
    /// Optional API key for the container.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub container_api_key: Option<String>,
    /// Environment name within the container.
    pub env_name: String,
    /// Policy configuration.
    pub policy: PolicyConfig,
    /// GEPA-specific configuration.
    pub gepa: GepaConfig,
}

fn default_gepa_algorithm() -> String {
    "gepa".to_string()
}

impl Default for GepaJobRequest {
    fn default() -> Self {
        Self {
            algorithm: default_gepa_algorithm(),
            container_url: String::new(),
            container_worker_token: None,
            container_api_key: None,
            env_name: "default".to_string(),
            policy: PolicyConfig::default(),
            gepa: GepaConfig::default(),
        }
    }
}

/// Request to submit a MIPRO optimization job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MiproJobRequest {
    /// Algorithm name (always "mipro").
    #[serde(default = "default_mipro_algorithm")]
    pub algorithm: String,
    /// Container URL.
    pub container_url: String,
    /// Optional SynthTunnel worker token (sent via header, not body).
    #[serde(default, skip_serializing)]
    pub container_worker_token: Option<String>,
    /// Optional API key for the container.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub container_api_key: Option<String>,
    /// Environment name.
    pub env_name: String,
    /// Policy configuration.
    pub policy: PolicyConfig,
    /// MIPRO-specific configuration.
    pub mipro: MiproConfig,
}

fn default_mipro_algorithm() -> String {
    "mipro".to_string()
}

impl Default for MiproJobRequest {
    fn default() -> Self {
        Self {
            algorithm: default_mipro_algorithm(),
            container_url: String::new(),
            container_worker_token: None,
            container_api_key: None,
            env_name: "default".to_string(),
            policy: PolicyConfig::default(),
            mipro: MiproConfig::default(),
        }
    }
}

/// Request to submit an evaluation job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalJobRequest {
    /// Optional app identifier.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub app_id: Option<String>,
    /// Container URL.
    pub container_url: String,
    /// Optional SynthTunnel worker token (sent via header, not body).
    #[serde(default, skip_serializing)]
    pub container_worker_token: Option<String>,
    /// Optional API key for the container.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub container_api_key: Option<String>,
    /// Environment name.
    pub env_name: String,
    /// Optional environment configuration payload.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub env_config: Option<Value>,
    /// Optional verifier configuration.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verifier_config: Option<Value>,
    /// Seeds to evaluate.
    pub seeds: Vec<i64>,
    /// Policy configuration.
    pub policy: PolicyConfig,
    /// Maximum concurrent evaluations.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_concurrent: Option<i32>,
    /// Optional timeout in seconds.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub timeout: Option<f64>,
}

impl Default for EvalJobRequest {
    fn default() -> Self {
        Self {
            app_id: None,
            container_url: String::new(),
            container_worker_token: None,
            container_api_key: None,
            env_name: "default".to_string(),
            env_config: None,
            verifier_config: None,
            seeds: vec![],
            policy: PolicyConfig::default(),
            max_concurrent: None,
            timeout: None,
        }
    }
}

// =============================================================================
// Job Results
// =============================================================================

/// Response from submitting a job.
#[derive(Debug, Clone, Deserialize)]
pub struct JobSubmitResponse {
    /// The job ID.
    pub job_id: String,
}

/// Result of a policy optimization job (GEPA/MIPRO).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptLearningResult {
    /// Job ID.
    pub job_id: String,
    /// Current status.
    pub status: PolicyJobStatus,
    /// Best reward achieved (if available).
    #[serde(skip_serializing_if = "Option::is_none", alias = "best_score")]
    pub best_reward: Option<f64>,
    /// Best candidate found (if available).
    #[serde(skip_serializing_if = "Option::is_none", alias = "best_prompt")]
    pub best_candidate: Option<Value>,
    /// Lever summary emitted by optimizer runtimes.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lever_summary: Option<Value>,
    /// Sensor frame summaries emitted by optimizer runtimes.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sensor_frames: Vec<Value>,
    /// Lever version pins for the selected/best candidate.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub lever_versions: HashMap<String, i64>,
    /// Highest lever version in `lever_versions`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub best_lever_version: Option<i64>,
    /// Error message (if failed).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// Number of generations completed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub generations_completed: Option<i32>,
    /// Total candidates evaluated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub candidates_evaluated: Option<i32>,
}

impl PromptLearningResult {
    /// Parse the MIPRO lever summary payload (if the backend provided it).
    pub fn mipro_lever_summary(&self) -> Option<crate::data::MiproLeverSummary> {
        let value = self.lever_summary.as_ref()?;
        serde_json::from_value::<crate::data::MiproLeverSummary>(value.clone()).ok()
    }

    /// Parse sensor frame summaries into typed structs (best-effort).
    pub fn sensor_frame_summaries(&self) -> Vec<crate::data::SensorFrameSummary> {
        self.sensor_frames
            .iter()
            .filter_map(|value| {
                serde_json::from_value::<crate::data::SensorFrameSummary>(value.clone()).ok()
            })
            .collect()
    }
}

#[cfg(test)]
mod prompt_learning_result_tests {
    use super::PromptLearningResult;

    #[test]
    fn parses_mipro_lever_summary_and_sensor_frames() {
        let payload = serde_json::json!({
            "job_id": "pl_test",
            "status": "succeeded",
            "best_reward": 1.0,
            "best_candidate": null,
            "lever_summary": {
                "prompt_lever_id": "mipro.prompt.sys",
                "candidate_lever_versions": {"baseline": 1},
                "baseline_candidate_id": "baseline",
                "lever_count": 1,
                "mutation_count": 0,
                "latest_version": 1
            },
            "sensor_frames": [
                {
                    "frame_id": "frame_1",
                    "created_at": "2026-02-11T12:00:00Z",
                    "sensor_count": 2,
                    "sensor_kinds": ["reward", "resource"],
                    "trace_ids": ["trace_1"],
                    "lever_versions": {"mipro.prompt.sys": 1}
                }
            ],
            "lever_versions": {"mipro.prompt.sys": 1}
        });

        let result: PromptLearningResult = serde_json::from_value(payload).unwrap();
        let lever_summary = result.mipro_lever_summary().unwrap();
        assert_eq!(
            lever_summary.prompt_lever_id.as_deref(),
            Some("mipro.prompt.sys")
        );

        let frames = result.sensor_frame_summaries();
        assert_eq!(frames.len(), 1);
        assert_eq!(frames[0].frame_id, "frame_1");
        assert_eq!(frames[0].sensor_count, 2);
    }
}

/// Result of an evaluation job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalResult {
    /// Job ID.
    pub job_id: String,
    /// Current status.
    pub status: EvalJobStatus,
    /// Mean reward across all seeds.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mean_reward: Option<f64>,
    /// Total tokens used.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub total_tokens: Option<i64>,
    /// Number of seeds completed.
    #[serde(default)]
    pub num_completed: i32,
    /// Total number of seeds.
    #[serde(default)]
    pub num_total: i32,
    /// Error message (if failed).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

// =============================================================================
// Graph/Verifier Types
// =============================================================================

/// Request for a graph completion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphCompletionRequest {
    /// Graph/job ID (e.g., "zero_shot_verifier_rubric_single").
    pub job_id: String,
    /// Input to the graph.
    pub input: Value,
    /// Optional model override.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Optional prompt snapshot ID.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prompt_snapshot_id: Option<String>,
    /// Whether to stream the response.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stream: Option<bool>,
}

/// Usage statistics from a graph completion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Usage {
    /// Prompt tokens used.
    #[serde(default)]
    pub prompt_tokens: i64,
    /// Completion tokens generated.
    #[serde(default)]
    pub completion_tokens: i64,
    /// Total tokens.
    #[serde(default)]
    pub total_tokens: i64,
}

/// Response from a graph completion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphCompletionResponse {
    /// Output from the graph.
    pub output: Value,
    /// Token usage (if available).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub usage: Option<Usage>,
    /// Cache status (hit/miss).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cache_status: Option<String>,
    /// Optional latency in milliseconds.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub latency_ms: Option<f64>,
}

/// Options for verifier inference.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VerifierOptions {
    /// Model to use for verification.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Verifier graph ID (default: zero_shot_verifier_rubric_single).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verifier_id: Option<String>,
    /// Whether to save evidence locally.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub save_evidence: Option<bool>,
}

/// A single review payload from the verifier.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewPayload {
    /// Score (0.0 to 1.0).
    #[serde(default)]
    pub score: f64,
    /// Reasoning for the score.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reasoning: Option<String>,
    /// Objective being evaluated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub objective: Option<String>,
}

/// Evidence item from verifier.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceItem {
    /// Type of evidence.
    #[serde(rename = "type")]
    pub evidence_type: String,
    /// Evidence content.
    pub content: Value,
}

/// Response from verifier inference.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifierResponse {
    /// Status of the verification.
    pub status: String,
    /// Reviews for individual events.
    #[serde(default)]
    pub event_reviews: Vec<ReviewPayload>,
    /// Overall outcome review.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub outcome_review: Option<ReviewPayload>,
    /// Objective scores.
    #[serde(default)]
    pub objectives: HashMap<String, f64>,
    /// Evidence collected.
    #[serde(default)]
    pub evidence: Vec<EvidenceItem>,
}

/// Options for RLM (Retrieval-augmented LM) inference.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RlmOptions {
    /// Model to use.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// RLM graph ID.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rlm_id: Option<String>,
    /// Maximum context tokens.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_context_tokens: Option<i32>,
}

// =============================================================================
// Cancel Request
// =============================================================================

/// Request to cancel a job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CancelRequest {
    /// Optional reason for cancellation.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

// =============================================================================
// Pause/Resume Request
// =============================================================================

/// Request to pause or resume a job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PauseRequest {
    /// Optional reason for pausing/resuming.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

// =============================================================================
// Container Deployments
// =============================================================================

fn default_container_timeout_s() -> i32 {
    600
}

fn default_container_cpu_cores() -> i32 {
    2
}

fn default_container_memory_mb() -> i32 {
    4096
}

fn default_container_dockerfile_path() -> String {
    "Dockerfile".to_string()
}

fn default_container_entrypoint_mode() -> String {
    "stdio".to_string()
}

fn default_container_port() -> i32 {
    8000
}

/// Resource limits for managed Container deployments.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerLimits {
    /// Timeout in seconds for a rollout request.
    #[serde(default = "default_container_timeout_s")]
    pub timeout_s: i32,
    /// CPU cores allocated to the deployment.
    #[serde(default = "default_container_cpu_cores")]
    pub cpu_cores: i32,
    /// Memory allocation in MB.
    #[serde(default = "default_container_memory_mb")]
    pub memory_mb: i32,
}

impl Default for ContainerLimits {
    fn default() -> Self {
        Self {
            timeout_s: default_container_timeout_s(),
            cpu_cores: default_container_cpu_cores(),
            memory_mb: default_container_memory_mb(),
        }
    }
}

/// Deployment specification for managed Container builds.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerDeploySpec {
    /// Deployment name (org-unique).
    pub name: String,
    /// Dockerfile path inside the build context.
    #[serde(default = "default_container_dockerfile_path")]
    pub dockerfile_path: String,
    /// Command to start the Container server.
    pub entrypoint: String,
    /// Entry point mode ("stdio" or "command").
    #[serde(default = "default_container_entrypoint_mode")]
    pub entrypoint_mode: String,
    /// Port exposed by the Container server.
    #[serde(default = "default_container_port")]
    pub port: i32,
    /// Optional deployment description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Environment variables to set in the deployment.
    #[serde(default)]
    pub env_vars: HashMap<String, String>,
    /// Resource limits for the deployment.
    #[serde(default)]
    pub limits: ContainerLimits,
    /// Additional metadata.
    #[serde(default)]
    pub metadata: HashMap<String, Value>,
}

/// Response for a Container deployment request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerDeployResponse {
    pub deployment_id: String,
    pub status: String,
    pub container_url: String,
    #[serde(default)]
    pub container_api_key_env: Option<String>,
}

/// Status response for Container deployments.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerDeployStatus {
    pub deployment_id: String,
    pub status: String,
    pub provider: String,
    #[serde(default)]
    pub error: Option<String>,
}

/// Deployment list entry for Container deployments.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerDeploymentInfo {
    pub deployment_id: String,
    pub name: String,
    pub status: String,
    pub provider: String,
    pub container_url: String,
    #[serde(default)]
    pub created_at: Option<String>,
    #[serde(default)]
    pub updated_at: Option<String>,
}

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

    #[test]
    fn test_policy_job_status_terminal() {
        assert!(!PolicyJobStatus::Pending.is_terminal());
        assert!(!PolicyJobStatus::Running.is_terminal());
        assert!(!PolicyJobStatus::Paused.is_terminal());
        assert!(PolicyJobStatus::Succeeded.is_terminal());
        assert!(PolicyJobStatus::Failed.is_terminal());
        assert!(PolicyJobStatus::Cancelled.is_terminal());
    }

    #[test]
    fn test_policy_job_status_from_str() {
        assert_eq!(
            PolicyJobStatus::from_str("pending"),
            Some(PolicyJobStatus::Pending)
        );
        assert_eq!(
            PolicyJobStatus::from_str("RUNNING"),
            Some(PolicyJobStatus::Running)
        );
        assert_eq!(
            PolicyJobStatus::from_str("paused"),
            Some(PolicyJobStatus::Paused)
        );
        assert_eq!(
            PolicyJobStatus::from_str("cancelled"),
            Some(PolicyJobStatus::Cancelled)
        );
        assert_eq!(
            PolicyJobStatus::from_str("canceled"),
            Some(PolicyJobStatus::Cancelled)
        );
        assert_eq!(PolicyJobStatus::from_str("invalid"), None);
    }

    #[test]
    fn test_gepa_request_serialization() {
        let req = GepaJobRequest {
            container_url: "http://localhost:8000".to_string(),
            env_name: "test".to_string(),
            ..Default::default()
        };
        let json = serde_json::to_string(&req).unwrap();
        assert!(json.contains("\"algorithm\":\"gepa\""));
        assert!(json.contains("\"container_url\":\"http://localhost:8000\""));
    }
}
