use eventsource_stream::Eventsource;
use futures_util::StreamExt;
use reqwest::{header, Method};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use std::path::Path;

use crate::error::{OriginError, Result};
use crate::transport::HttpTransport;
use crate::types::{
    AssetDeleteResponse, AssetListResponse, AssetType, AsyncSubmitOptions, AudioOptions,
    BatchGenerateRequest, BatchGenerateResponse, CharacterOptions, GenerateRequest,
    GenerateResponse, GeneratedAssetOptions, ImageOptions, JobCancelResponse, JobEvent,
    JobListQuery, JobProgress, JobStatusResponse, JobSummary, LibraryAddRequest,
    LibraryAddResponse, LibraryAdminBatchResponse, LibraryBackfillRequest, LibraryBundleRequest,
    LibraryCatalogJobsRequest, LibraryCatalogJobsResponse, LibraryDeleteResponse,
    LibraryIngestJobInfo, LibraryIngestRequest, LibraryIngestResponse, LibraryIngestResult,
    LibraryItem, LibraryPackDeleteResponse, LibraryPackInstallRequest, LibraryPackInstallResponse,
    LibraryPackListResponse, LibraryPackRefreshResponse, LibraryReenrichRequest,
    LibraryRelatedQuery, LibraryRelatedResponse, LibrarySearchQuery, LibrarySearchResponse,
    LoginResponse, Model3dOptions, MusicOptions, PackOnlyResponse, Process3dRequest,
    Process3dResponse, ProcessRequest, ProcessResponse, PropOptions, ProviderHealthListResponse,
    ProviderHealthResponse, ProviderInfo, SpriteOptions, SubmitAck, TextOptions, TtsOptions,
    UploadResult, VideoOptions, VoiceCloneRequest, VoiceCloneResponse, VoiceDeleteResponse,
    VoiceDesignRequest, VoiceDesignResponse, VoiceListResponse, VoiceType, WorldOptions,
};

/// Default Asset Gateway endpoint used by [`AssetClient`].
pub const DEFAULT_BASE_URL: &str = "https://asset.origingame.dev";

/// Builder for configuring an [`AssetClient`].
#[derive(Debug, Clone)]
pub struct AssetClientBuilder {
    api_key: String,
    base_url: String,
    client: Option<reqwest::Client>,
}

impl AssetClientBuilder {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            api_key: api_key.into(),
            base_url: DEFAULT_BASE_URL.to_string(),
            client: None,
        }
    }

    /// Override the Asset Gateway base URL.
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = url.into();
        self
    }

    /// Use a custom `reqwest::Client` (e.g. custom timeout, proxy, TLS config).
    pub fn http_client(mut self, client: reqwest::Client) -> Self {
        self.client = Some(client);
        self
    }

    pub fn build(self) -> AssetClient {
        let transport = HttpTransport::new(self.base_url, self.api_key);
        let transport = if let Some(client) = self.client {
            transport.with_client(client)
        } else {
            transport
        };

        AssetClient { transport }
    }
}

/// Client for the Origin Asset Gateway service.
#[derive(Debug, Clone)]
pub struct AssetClient {
    pub(crate) transport: HttpTransport,
}

impl AssetClient {
    /// Create a client with the default Asset Gateway base URL.
    pub fn new(api_key: impl Into<String>) -> Self {
        AssetClientBuilder::new(api_key).build()
    }

    /// Create a builder for fine-grained configuration.
    pub fn builder(api_key: impl Into<String>) -> AssetClientBuilder {
        AssetClientBuilder::new(api_key)
    }

    /// Return the configured Asset Gateway base URL.
    pub fn base_url(&self) -> &str {
        self.transport.base_url()
    }

    pub async fn generate(&self, request: &GenerateRequest) -> Result<GenerateResponse> {
        let body = json!({
            "asset_type": request.asset_type,
            "prompt": request.prompt,
            "model": request.model,
            "input_file": request.input_file,
            "provider": request.provider,
            "size": request.size,
            "transparent": request.transparent,
            "reference_images": request.reference_images,
            "edit_mode": request.edit_mode,
            "session_id": request.session_id,
            "params": request.params,
        });

        self.transport.post("/api/generate", &body).await
    }

    pub async fn generate_async(
        &self,
        request: &GenerateRequest,
        options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        self.transport
            .submit_async("/api/generate", request, &async_options(options))
            .await
    }

    pub async fn generate_image(
        &self,
        prompt: impl Into<String>,
        options: Option<ImageOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::Image,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: options.input,
            provider: options.provider,
            size: options.size,
            transparent: options.transparent,
            reference_images: options.reference_images,
            edit_mode: options.edit_mode,
            session_id: options.session_id,
            params: merge_params(
                options.params,
                [
                    option_entry("quality", options.quality),
                    option_entry("background", options.background),
                    option_entry("output_format", options.output_format),
                    option_entry("n", options.n.map(Value::from)),
                    option_entry("preset", options.preset),
                    option_entry("mask", options.mask),
                    option_entry("mask_url", options.mask_url),
                ],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_animation(
        &self,
        prompt: impl Into<String>,
        options: Option<GeneratedAssetOptions>,
    ) -> Result<GenerateResponse> {
        let request = build_generated_asset_request(
            AssetType::Animation,
            prompt.into(),
            options.unwrap_or_default(),
        );
        self.generate(&request).await
    }

    pub async fn generate_material(
        &self,
        _prompt: impl Into<String>,
        _options: Option<GeneratedAssetOptions>,
    ) -> Result<PackOnlyResponse> {
        Ok(pack_only_response("material"))
    }

    pub async fn generate_hdri(
        &self,
        _prompt: impl Into<String>,
        _options: Option<GeneratedAssetOptions>,
    ) -> Result<PackOnlyResponse> {
        Ok(pack_only_response("hdri"))
    }

    pub async fn generate_vfx(
        &self,
        _prompt: impl Into<String>,
        _options: Option<GeneratedAssetOptions>,
    ) -> Result<PackOnlyResponse> {
        Ok(pack_only_response("vfx"))
    }

    pub async fn generate_lut(
        &self,
        _prompt: impl Into<String>,
        _options: Option<GeneratedAssetOptions>,
    ) -> Result<PackOnlyResponse> {
        Ok(pack_only_response("lut"))
    }

    pub async fn generate_shader(
        &self,
        _prompt: impl Into<String>,
        _options: Option<GeneratedAssetOptions>,
    ) -> Result<PackOnlyResponse> {
        Ok(pack_only_response("shader"))
    }

    pub async fn generate_font_typeface(
        &self,
        _prompt: impl Into<String>,
        _options: Option<GeneratedAssetOptions>,
    ) -> Result<PackOnlyResponse> {
        Ok(pack_only_response("font_typeface"))
    }

    pub async fn generate_skybox(
        &self,
        prompt: impl Into<String>,
        options: Option<GeneratedAssetOptions>,
    ) -> Result<GenerateResponse> {
        let request = build_generated_asset_request(
            AssetType::Skybox,
            prompt.into(),
            options.unwrap_or_default(),
        );
        self.generate(&request).await
    }

    pub async fn generate_decal(
        &self,
        prompt: impl Into<String>,
        options: Option<GeneratedAssetOptions>,
    ) -> Result<GenerateResponse> {
        let request = build_generated_asset_request(
            AssetType::Decal,
            prompt.into(),
            options.unwrap_or_default(),
        );
        self.generate(&request).await
    }

    pub async fn generate_heightmap(
        &self,
        prompt: impl Into<String>,
        options: Option<GeneratedAssetOptions>,
    ) -> Result<GenerateResponse> {
        let request = build_generated_asset_request(
            AssetType::Heightmap,
            prompt.into(),
            options.unwrap_or_default(),
        );
        self.generate(&request).await
    }

    pub async fn generate_video(
        &self,
        prompt: impl Into<String>,
        options: Option<VideoOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let mut params = options.params;
        if let Some(ratio) = &options.ratio {
            if let Value::Object(ref mut map) = params {
                map.insert("ratio".to_string(), Value::String(ratio.clone()));
            } else {
                params = serde_json::json!({ "ratio": ratio });
            }
        }
        let request = GenerateRequest {
            asset_type: AssetType::Video,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: None,
            provider: options.provider,
            size: options.size,
            transparent: None,
            reference_images: options.reference_images,
            edit_mode: None,
            session_id: None,
            params,
        };

        self.generate(&request).await
    }

    pub async fn generate_audio(
        &self,
        prompt: impl Into<String>,
        options: Option<AudioOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::Audio,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: None,
            provider: options.provider,
            size: None,
            transparent: None,
            reference_images: Vec::new(),
            edit_mode: None,
            session_id: None,

            params: merge_params(
                options.params,
                [
                    option_entry("audio_type", options.audio_type),
                    option_entry("duration_seconds", options.duration.map(Value::from)),
                ],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_tts(
        &self,
        prompt: impl Into<String>,
        options: Option<TtsOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::Tts,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: None,
            provider: options.provider,
            size: None,
            transparent: None,
            reference_images: Vec::new(),
            edit_mode: None,
            session_id: None,

            params: merge_params(
                options.params,
                [
                    option_entry("voice", options.voice),
                    option_entry("voice_id", options.voice_id),
                    option_entry(
                        "context",
                        options.context.or_else(|| options.instructions.clone()),
                    ),
                    option_entry("style", options.style),
                    option_entry("language_type", options.language),
                    option_entry("instructions", options.instructions),
                ],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_music(
        &self,
        prompt: impl Into<String>,
        options: Option<MusicOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::Music,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: None,
            provider: options.provider,
            size: None,
            transparent: None,
            reference_images: Vec::new(),
            edit_mode: None,
            session_id: None,

            params: merge_params(
                options.params,
                [
                    option_entry("duration_seconds", options.duration.map(Value::from)),
                    bool_entry("force_instrumental", options.force_instrumental),
                    option_entry("output_format", options.output_format),
                ],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_model3d(
        &self,
        prompt: impl Into<String>,
        options: Option<Model3dOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::Model3d,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: options.input,
            provider: options.provider,
            size: None,
            transparent: None,
            reference_images: Vec::new(),
            edit_mode: None,
            session_id: None,

            params: merge_params(
                options.params,
                [
                    option_entry("ai_model", options.ai_model),
                    option_entry("target_polycount", options.polycount.map(Value::from)),
                    bool_entry("pbr", options.pbr),
                    bool_entry("hd_texture", options.hd_texture),
                    bool_entry("auto_size", options.auto_size),
                    option_entry("pose_mode", options.pose_mode),
                    option_entry("texture_prompt", options.texture_prompt),
                    option_entry("format", options.format),
                    option_entry(
                        "images",
                        (!options.images.is_empty()).then(|| {
                            Value::Array(options.images.into_iter().map(Value::from).collect())
                        }),
                    ),
                ],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_character(
        &self,
        prompt: impl Into<String>,
        options: Option<CharacterOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::Character,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: options.input,
            provider: options.provider,
            size: None,
            transparent: None,
            reference_images: Vec::new(),
            edit_mode: None,
            session_id: None,

            params: merge_params(
                options.params,
                [
                    option_entry("format", options.format),
                    option_entry("target_polycount", options.polycount.map(Value::from)),
                    bool_entry("pbr", options.pbr),
                    bool_entry("hd_texture", options.hd_texture),
                    option_entry("pose_mode", options.pose_mode),
                    option_entry("ai_model", options.ai_model),
                    bool_entry("auto_size", options.auto_size),
                    option_entry("texture_prompt", options.texture_prompt),
                    option_entry(
                        "images",
                        (!options.images.is_empty()).then(|| {
                            Value::Array(options.images.into_iter().map(Value::from).collect())
                        }),
                    ),
                ],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_prop(
        &self,
        prompt: impl Into<String>,
        options: Option<PropOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::Prop,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: options.input,
            provider: options.provider,
            size: None,
            transparent: None,
            reference_images: Vec::new(),
            edit_mode: None,
            session_id: None,

            params: merge_params(
                options.params,
                [
                    option_entry("format", options.format),
                    option_entry("target_polycount", options.polycount.map(Value::from)),
                    bool_entry("pbr", options.pbr),
                    bool_entry("hd_texture", options.hd_texture),
                    option_entry("ai_model", options.ai_model),
                    bool_entry("auto_size", options.auto_size),
                    option_entry("texture_prompt", options.texture_prompt),
                    option_entry("symmetry_mode", options.symmetry_mode),
                    option_entry(
                        "images",
                        (!options.images.is_empty()).then(|| {
                            Value::Array(options.images.into_iter().map(Value::from).collect())
                        }),
                    ),
                ],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_sprite(
        &self,
        prompt: impl Into<String>,
        options: Option<SpriteOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::Sprite,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: options.input,
            provider: options.provider,
            size: None,
            transparent: None,
            reference_images: Vec::new(),
            edit_mode: None,
            session_id: None,

            params: merge_params(
                options.params,
                [
                    option_entry(
                        "animation_type",
                        Some(Value::String(
                            options.animation_type.unwrap_or_else(|| "walk".to_string()),
                        )),
                    ),
                    option_entry(
                        "direction",
                        Some(Value::String(
                            options.direction.unwrap_or_else(|| "right".to_string()),
                        )),
                    ),
                    option_entry("duration", Some(Value::from(options.duration.unwrap_or(2)))),
                    option_entry(
                        "output_format",
                        Some(Value::String(
                            options
                                .output_format
                                .unwrap_or_else(|| "spritesheet".to_string()),
                        )),
                    ),
                    option_entry("fps", options.fps.map(Value::from)),
                    option_entry("style", options.style),
                ],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_world(
        &self,
        prompt: impl Into<String>,
        options: Option<WorldOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::World,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: options.input.clone().or_else(|| options.image_url.clone()),
            provider: options.provider,
            size: None,
            transparent: None,
            reference_images: Vec::new(),
            edit_mode: None,
            session_id: None,

            params: merge_params(
                options.params,
                [
                    option_entry("display_name", options.display_name),
                    option_entry("image_url", options.image_url),
                    option_entry("panorama_url", options.panorama_url),
                    option_entry("video_url", options.video_url),
                    option_entry(
                        "multi_image_url",
                        (!options.multi_image_url.is_empty()).then(|| {
                            Value::Array(
                                options
                                    .multi_image_url
                                    .into_iter()
                                    .map(Value::from)
                                    .collect(),
                            )
                        }),
                    ),
                    option_entry("quality", options.quality),
                ],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_text(
        &self,
        prompt: impl Into<String>,
        options: Option<TextOptions>,
    ) -> Result<GenerateResponse> {
        let options = options.unwrap_or_default();
        let request = GenerateRequest {
            asset_type: AssetType::Text,
            prompt: Some(prompt.into()),
            model: options.model,
            input_file: None,
            provider: options.provider,
            size: None,
            transparent: None,
            reference_images: Vec::new(),
            edit_mode: None,
            session_id: None,

            params: merge_params(
                options.params,
                [option_entry(
                    "max_tokens",
                    options.max_tokens.map(Value::from),
                )],
            ),
        };

        self.generate(&request).await
    }

    pub async fn generate_image_async(
        &self,
        prompt: impl Into<String>,
        options: Option<ImageOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_image_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_animation_async(
        &self,
        prompt: impl Into<String>,
        options: Option<GeneratedAssetOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_generated_asset_request(
            AssetType::Animation,
            prompt.into(),
            options.unwrap_or_default(),
        );
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_skybox_async(
        &self,
        prompt: impl Into<String>,
        options: Option<GeneratedAssetOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_generated_asset_request(
            AssetType::Skybox,
            prompt.into(),
            options.unwrap_or_default(),
        );
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_decal_async(
        &self,
        prompt: impl Into<String>,
        options: Option<GeneratedAssetOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_generated_asset_request(
            AssetType::Decal,
            prompt.into(),
            options.unwrap_or_default(),
        );
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_heightmap_async(
        &self,
        prompt: impl Into<String>,
        options: Option<GeneratedAssetOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_generated_asset_request(
            AssetType::Heightmap,
            prompt.into(),
            options.unwrap_or_default(),
        );
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_video_async(
        &self,
        prompt: impl Into<String>,
        options: Option<VideoOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_video_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_audio_async(
        &self,
        prompt: impl Into<String>,
        options: Option<AudioOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_audio_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_tts_async(
        &self,
        prompt: impl Into<String>,
        options: Option<TtsOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_tts_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_music_async(
        &self,
        prompt: impl Into<String>,
        options: Option<MusicOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_music_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_model3d_async(
        &self,
        prompt: impl Into<String>,
        options: Option<Model3dOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_model3d_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_character_async(
        &self,
        prompt: impl Into<String>,
        options: Option<CharacterOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_character_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_prop_async(
        &self,
        prompt: impl Into<String>,
        options: Option<PropOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_prop_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_sprite_async(
        &self,
        prompt: impl Into<String>,
        options: Option<SpriteOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_sprite_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_world_async(
        &self,
        prompt: impl Into<String>,
        options: Option<WorldOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_world_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn generate_text_async(
        &self,
        prompt: impl Into<String>,
        options: Option<TextOptions>,
        submit_options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        let request = build_text_request(prompt.into(), options.unwrap_or_default());
        self.generate_async(&request, submit_options).await
    }

    pub async fn batch(&self, request: &BatchGenerateRequest) -> Result<BatchGenerateResponse> {
        let body = json!({
            "asset_type": request.asset_type,
            "prompts": request.prompts,
            "shared": request.shared,
            "compose": request.compose,
        });

        self.transport.post("/api/generate/batch", &body).await
    }

    pub async fn batch_async(
        &self,
        request: &BatchGenerateRequest,
        options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        self.transport
            .submit_async("/api/generate/batch", request, &async_options(options))
            .await
    }

    pub async fn process(&self, request: &ProcessRequest) -> Result<ProcessResponse> {
        let body = json!({
            "input": request.input,
            "inputs": request.inputs,
            "operations": request.operations,
        });

        self.transport.post("/api/process", &body).await
    }

    pub async fn process_async(
        &self,
        request: &ProcessRequest,
        options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        self.transport
            .submit_async("/api/process", request, &async_options(options))
            .await
    }

    pub async fn process3d(&self, request: &Process3dRequest) -> Result<Process3dResponse> {
        let body = json!({
            "task_id": request.task_id,
            "operation": request.operation,
            "params": request.params,
            "frame_count": request.frame_count,
            "resolution": request.resolution,
            "camera_angle": request.camera_angle,
            "directions": request.directions,
        });

        self.transport.post("/api/process3d", &body).await
    }

    pub async fn process3d_async(
        &self,
        request: &Process3dRequest,
        options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        self.transport
            .submit_async("/api/process3d", request, &async_options(options))
            .await
    }

    pub async fn voice_clone(&self, request: &VoiceCloneRequest) -> Result<VoiceCloneResponse> {
        let body = serde_json::to_value(request)?;
        self.transport.post("/api/voice/clone", &body).await
    }

    pub async fn voice_clone_async(
        &self,
        request: &VoiceCloneRequest,
        options: Option<AsyncSubmitOptions>,
    ) -> Result<SubmitAck> {
        self.transport
            .submit_async("/api/voice/clone", request, &async_options(options))
            .await
    }

    pub async fn voice_design(&self, request: &VoiceDesignRequest) -> Result<VoiceDesignResponse> {
        let body = serde_json::to_value(request)?;
        self.transport.post("/api/voice/design", &body).await
    }

    pub async fn voice_list(
        &self,
        voice_type: Option<VoiceType>,
        page: Option<u32>,
        page_size: Option<u32>,
    ) -> Result<VoiceListResponse> {
        let mut path = String::from("/api/voice/list");
        let mut has_query = false;

        if let Some(voice_type) = voice_type {
            push_query_param(&mut path, &mut has_query, "type", voice_type.as_str());
        }
        if let Some(page) = page {
            push_query_param(&mut path, &mut has_query, "page", &page.to_string());
        }
        if let Some(page_size) = page_size {
            push_query_param(
                &mut path,
                &mut has_query,
                "page_size",
                &page_size.to_string(),
            );
        }

        self.transport.get(&path).await
    }

    pub async fn voice_delete(
        &self,
        voice_id: &str,
        voice_type: Option<VoiceType>,
    ) -> Result<VoiceDeleteResponse> {
        let mut path = format!("/api/voice/{}", percent_encode(voice_id));
        let mut has_query = false;

        if let Some(voice_type) = voice_type {
            push_query_param(&mut path, &mut has_query, "type", voice_type.as_str());
        }

        self.transport.delete(&path).await
    }

    pub async fn upload_file(&self, file_path: impl AsRef<Path>) -> Result<UploadResult> {
        let file_path = file_path.as_ref();
        let bytes = tokio::fs::read(file_path).await?;
        let filename = file_path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("upload")
            .to_string();
        let part = reqwest::multipart::Part::bytes(bytes).file_name(filename);
        let form = reqwest::multipart::Form::new().part("file", part);

        self.transport
            .post_multipart("/api/assets/upload", form)
            .await
    }

    pub async fn list_assets(&self) -> Result<AssetListResponse> {
        self.transport.get("/api/assets").await
    }

    pub async fn delete_asset(&self, filename: &str) -> Result<AssetDeleteResponse> {
        self.transport
            .delete(&format!("/api/assets/{}", percent_encode(filename)))
            .await
    }

    pub async fn library_search(
        &self,
        query: &LibrarySearchQuery,
    ) -> Result<LibrarySearchResponse> {
        let mut path = String::from("/api/library/search");
        let mut has_query = false;

        if let Some(q) = query.q.as_deref().or(query.query.as_deref()) {
            push_query_param(&mut path, &mut has_query, "q", q);
        }
        if let Some(asset_type) = query.r#type.as_deref() {
            push_query_param(&mut path, &mut has_query, "type", asset_type);
        }
        if let Some(tags) = query.tags.as_deref() {
            if !tags.trim().is_empty() {
                push_query_param(&mut path, &mut has_query, "tags", tags);
            }
        }
        if let Some(source) = query.source.as_deref() {
            push_query_param(&mut path, &mut has_query, "source", source);
        }
        if let Some(pack_id) = query.pack_id.as_deref() {
            push_query_param(&mut path, &mut has_query, "pack_id", pack_id);
        }
        if let Some(mode) = query.mode {
            push_query_param(&mut path, &mut has_query, "mode", mode.as_str());
        }
        if let Some(style) = query.style.as_deref() {
            push_query_param(&mut path, &mut has_query, "style", style);
        }
        if let Some(composition) = query.composition.as_deref() {
            push_query_param(&mut path, &mut has_query, "composition", composition);
        }
        if let Some(lighting) = query.lighting.as_deref() {
            push_query_param(&mut path, &mut has_query, "lighting", lighting);
        }
        if let Some(color_tone) = query.color_tone.as_deref() {
            push_query_param(&mut path, &mut has_query, "color_tone", color_tone);
        }
        if let Some(mood) = query.mood.as_deref() {
            push_query_param(&mut path, &mut has_query, "mood", mood);
        }
        if let Some(theme) = query.theme.as_deref() {
            push_query_param(&mut path, &mut has_query, "theme", theme);
        }
        if let Some(era) = query.era.as_deref() {
            push_query_param(&mut path, &mut has_query, "era", era);
        }
        if let Some(gameplay_role) = query.gameplay_role.as_deref() {
            push_query_param(&mut path, &mut has_query, "gameplay_role", gameplay_role);
        }
        if let Some(rig_name) = query.rig_name.as_deref() {
            push_query_param(&mut path, &mut has_query, "rig_name", rig_name);
        }
        if let Some(compatible_rig) = query.compatible_rig.as_deref() {
            push_query_param(&mut path, &mut has_query, "compatible_rig", compatible_rig);
        }
        if let Some(enrichment_status) = query.enrichment_status.as_deref() {
            push_query_param(
                &mut path,
                &mut has_query,
                "enrichment_status",
                enrichment_status,
            );
        }
        if let Some(min_long_edge_px) = query.min_long_edge_px {
            push_query_param(
                &mut path,
                &mut has_query,
                "min_long_edge_px",
                &min_long_edge_px.to_string(),
            );
        }
        if let Some(min_width_px) = query.min_width_px {
            push_query_param(
                &mut path,
                &mut has_query,
                "min_width_px",
                &min_width_px.to_string(),
            );
        }
        if let Some(min_height_px) = query.min_height_px {
            push_query_param(
                &mut path,
                &mut has_query,
                "min_height_px",
                &min_height_px.to_string(),
            );
        }
        if let Some(max_file_size_bytes) = query.max_file_size_bytes {
            push_query_param(
                &mut path,
                &mut has_query,
                "max_file_size_bytes",
                &max_file_size_bytes.to_string(),
            );
        }
        if let Some(license) = query.license.as_deref() {
            push_query_param(&mut path, &mut has_query, "license", license);
        }
        if let Some(rerank) = query.rerank {
            push_query_param(&mut path, &mut has_query, "rerank", &rerank.to_string());
        }
        if let Some(limit) = query.limit {
            push_query_param(&mut path, &mut has_query, "limit", &limit.to_string());
        }
        if let Some(offset) = query.offset {
            push_query_param(&mut path, &mut has_query, "offset", &offset.to_string());
        }

        self.transport.get(&path).await
    }

    pub async fn library_related(
        &self,
        id: &str,
        query: &LibraryRelatedQuery,
    ) -> Result<LibraryRelatedResponse> {
        let mut path = format!("/api/library/{}/related", percent_encode(id));
        let mut has_query = false;

        if let Some(asset_type) = query.r#type.as_deref() {
            push_query_param(&mut path, &mut has_query, "type", asset_type);
        }
        if let Some(limit) = query.limit {
            push_query_param(&mut path, &mut has_query, "limit", &limit.to_string());
        }

        self.transport.get(&path).await
    }

    pub async fn library_bundle(&self, request: &LibraryBundleRequest) -> Result<Vec<u8>> {
        let url = format!("{}/api/library/bundle", self.transport.base_url());
        let response = self
            .transport
            .client()
            .post(url)
            .bearer_auth(self.transport.api_key())
            .json(request)
            .send()
            .await?;
        let status = response.status();
        let bytes = response.bytes().await?;
        if !status.is_success() {
            return Err(OriginError::api(
                status.as_u16(),
                String::from_utf8_lossy(&bytes).to_string(),
            ));
        }
        Ok(bytes.to_vec())
    }

    pub async fn library_ingest(
        &self,
        request: &LibraryIngestRequest,
    ) -> Result<LibraryIngestResponse> {
        let body = serde_json::to_value(request)?;
        self.transport.post("/api/library/ingest", &body).await
    }

    pub async fn library_ingest_sync(
        &self,
        request: &LibraryIngestRequest,
    ) -> Result<LibraryIngestResult> {
        let body = serde_json::to_value(request)?;
        self.transport.post("/api/library/ingest/sync", &body).await
    }

    pub async fn library_ingest_status(&self, job_id: &str) -> Result<LibraryIngestJobInfo> {
        self.transport
            .get(&format!("/api/library/ingest/{}", percent_encode(job_id)))
            .await
    }

    pub async fn library_ingest_wait(
        &self,
        job_id: &str,
        interval_ms: u64,
    ) -> Result<LibraryIngestResult> {
        let interval = std::time::Duration::from_millis(interval_ms.max(250));
        loop {
            let job = self.library_ingest_status(job_id).await?;
            match job.status.as_str() {
                "completed" => return ingest_result_from_job(job),
                "failed" => {
                    return Err(OriginError::api(
                        0,
                        job.error_message
                            .unwrap_or_else(|| format!("library ingest job {job_id} failed")),
                    ));
                }
                "cancelled" => {
                    return Err(OriginError::job_cancelled(format!(
                        "library ingest job {job_id} cancelled"
                    )));
                }
                _ => tokio::time::sleep(interval).await,
            }
        }
    }

    pub async fn library_packs(&self) -> Result<LibraryPackListResponse> {
        self.transport.get("/api/library/packs").await
    }

    pub async fn library_packs_install(
        &self,
        request: &LibraryPackInstallRequest,
    ) -> Result<LibraryPackInstallResponse> {
        let body = serde_json::to_value(request)?;
        self.transport
            .post("/api/library/packs/install", &body)
            .await
    }

    pub async fn library_packs_delete(&self, id: &str) -> Result<LibraryPackDeleteResponse> {
        self.transport
            .delete(&format!("/api/library/packs/{}", percent_encode(id)))
            .await
    }

    pub async fn library_packs_refresh(&self, id: &str) -> Result<LibraryPackRefreshResponse> {
        self.transport
            .post(
                &format!("/api/library/packs/{}/refresh", percent_encode(id)),
                &json!({}),
            )
            .await
    }

    pub async fn library_add(&self, request: &LibraryAddRequest) -> Result<LibraryAddResponse> {
        let body = serde_json::to_value(request)?;
        self.transport.post("/api/library", &body).await
    }

    pub async fn library_get(&self, id: &str) -> Result<LibraryItem> {
        self.transport
            .get(&format!("/api/library/{}", percent_encode(id)))
            .await
    }

    pub async fn library_delete(&self, id: &str) -> Result<LibraryDeleteResponse> {
        self.transport
            .delete(&format!("/api/library/{}", percent_encode(id)))
            .await
    }

    pub async fn library_catalog_jobs(
        &self,
        request: &LibraryCatalogJobsRequest,
    ) -> Result<LibraryCatalogJobsResponse> {
        let body = serde_json::to_value(request)?;
        self.transport
            .post("/api/library/catalog-jobs", &body)
            .await
    }

    pub async fn library_backfill(
        &self,
        request: &LibraryBackfillRequest,
    ) -> Result<LibraryAdminBatchResponse> {
        let body = serde_json::to_value(request)?;
        self.transport
            .post("/api/library/admin/backfill", &body)
            .await
    }

    pub async fn library_reenrich(
        &self,
        request: &LibraryReenrichRequest,
    ) -> Result<LibraryAdminBatchResponse> {
        let body = serde_json::to_value(request)?;
        self.transport
            .post("/api/library/admin/reenrich", &body)
            .await
    }

    pub async fn jobs(&self, status: Option<&str>, limit: Option<u32>) -> Result<Vec<JobSummary>> {
        let query = JobListQuery {
            status: status.map(str::to_string),
            limit,
            ..Default::default()
        };
        self.jobs_filtered(&query).await
    }

    pub async fn jobs_filtered(&self, query: &JobListQuery) -> Result<Vec<JobSummary>> {
        let mut path = String::from("/api/jobs");
        let mut has_query = false;

        if let Some(job_kind) = query.job_kind.as_deref() {
            push_query_param(&mut path, &mut has_query, "job_kind", job_kind);
        }
        if let Some(status) = query.status.as_deref() {
            push_query_param(&mut path, &mut has_query, "status", status);
        }
        if let Some(asset_type) = query.asset_type.as_deref() {
            push_query_param(&mut path, &mut has_query, "asset_type", asset_type);
        }
        if let Some(provider_id) = query.provider_id.as_deref() {
            push_query_param(&mut path, &mut has_query, "provider_id", provider_id);
        }
        if let Some(since) = query.since.as_deref() {
            push_query_param(&mut path, &mut has_query, "since", since);
        }
        if let Some(provider_task_id) = query.provider_task_id.as_deref() {
            push_query_param(
                &mut path,
                &mut has_query,
                "provider_task_id",
                provider_task_id,
            );
        }
        if let Some(limit) = query.limit {
            push_query_param(&mut path, &mut has_query, "limit", &limit.to_string());
        }
        if let Some(offset) = query.offset {
            push_query_param(&mut path, &mut has_query, "offset", &offset.to_string());
        }

        let response: JobListResponse = self.transport.get(&path).await?;
        Ok(response.jobs)
    }

    pub async fn job_status(&self, job_id: &str) -> Result<JobStatusResponse> {
        self.transport
            .get(&format!("/api/jobs/{}", percent_encode(job_id)))
            .await
    }

    pub async fn providers(&self) -> Result<Vec<ProviderInfo>> {
        let response: ProviderListResponse = self.transport.get("/api/providers").await?;
        Ok(response.providers)
    }

    pub async fn health(&self) -> Result<bool> {
        let response: HealthResponse = self.transport.request(Method::GET, "/health", None).await?;
        Ok(response.status == "healthy")
    }

    pub async fn login(&self, token: Option<&str>) -> Result<LoginResponse> {
        let tok = token
            .map(String::from)
            .unwrap_or_else(|| self.transport.api_key().to_string());
        let body = json!({ "token": tok });
        self.transport.post("/auth/login", &body).await
    }

    pub async fn job_cancel(&self, job_id: &str) -> Result<JobCancelResponse> {
        self.transport
            .post(
                &format!("/api/jobs/{}/cancel", percent_encode(job_id)),
                &json!({}),
            )
            .await
    }

    pub async fn job_wait(
        &self,
        job_id: &str,
        interval_ms: Option<u64>,
    ) -> Result<JobStatusResponse> {
        let interval = std::time::Duration::from_millis(interval_ms.unwrap_or(1000).max(250));
        loop {
            let job: JobStatusResponse = self
                .transport
                .get(&format!("/api/jobs/{}", percent_encode(job_id)))
                .await?;
            match job.status.as_str() {
                "completed" | "failed" | "cancelled" => return Ok(job),
                _ => tokio::time::sleep(interval).await,
            }
        }
    }

    pub async fn job_wait_sse<F>(&self, job_id: &str, mut on_event: F) -> Result<JobStatusResponse>
    where
        F: FnMut(JobEvent) + Send,
    {
        match self.job_wait_sse_inner(job_id, &mut on_event).await {
            Ok(job) => terminal_job_result(job),
            Err(OriginError::JobCancelled { message, command }) => {
                Err(OriginError::JobCancelled { message, command })
            }
            Err(OriginError::JobWaitTimeout { message, command }) => {
                Err(OriginError::JobWaitTimeout { message, command })
            }
            Err(_) => {
                let job = self.job_wait(job_id, Some(1000)).await?;
                terminal_job_result(job)
            }
        }
    }

    async fn job_wait_sse_inner<F>(
        &self,
        job_id: &str,
        on_event: &mut F,
    ) -> Result<JobStatusResponse>
    where
        F: FnMut(JobEvent) + Send,
    {
        let url = format!(
            "{}/api/jobs/{}/events",
            self.transport.base_url(),
            percent_encode(job_id)
        );
        let response = self
            .transport
            .client()
            .get(url)
            .bearer_auth(self.transport.api_key())
            .header(header::ACCEPT, "text/event-stream")
            .send()
            .await?;

        if !response.status().is_success() {
            return Err(OriginError::api(
                response.status().as_u16(),
                "SSE job stream unavailable",
            ));
        }

        let mut stream = response.bytes_stream().eventsource();
        while let Some(event) = stream.next().await {
            let event = event.map_err(|error| OriginError::api(0, error.to_string()))?;
            let job_event = normalize_job_event(&event.event, &event.data);
            let terminal = is_terminal_status(&job_event.status);
            on_event(job_event);

            if terminal {
                return self.job_wait(job_id, Some(250)).await;
            }
        }

        Err(OriginError::api(
            0,
            "SSE job stream ended before completion",
        ))
    }

    pub async fn provider_health(&self) -> Result<ProviderHealthListResponse> {
        self.transport.get("/api/providers/health").await
    }

    pub async fn provider_health_single(&self, id: &str) -> Result<ProviderHealthResponse> {
        self.transport
            .get(&format!("/api/providers/{}/health", percent_encode(id)))
            .await
    }
}

#[derive(Debug, Deserialize)]
struct JobListResponse {
    jobs: Vec<JobSummary>,
}

#[derive(Debug, Deserialize)]
struct ProviderListResponse {
    providers: Vec<ProviderInfo>,
}

#[derive(Debug, Deserialize)]
struct HealthResponse {
    status: String,
}

fn merge_params<const N: usize>(base: Value, entries: [(String, Option<Value>); N]) -> Value {
    let mut params = match base {
        Value::Object(map) => map,
        _ => Map::new(),
    };

    for (key, value) in entries {
        if let Some(value) = value {
            params.insert(key, value);
        }
    }

    if params.is_empty() {
        Value::Null
    } else {
        Value::Object(params)
    }
}

fn option_entry<T>(key: &str, value: Option<T>) -> (String, Option<Value>)
where
    T: Into<Value>,
{
    (key.to_string(), value.map(Into::into))
}

fn bool_entry(key: &str, value: Option<bool>) -> (String, Option<Value>) {
    (
        key.to_string(),
        value.and_then(|enabled| enabled.then_some(Value::Bool(true))),
    )
}

fn push_query_param(path: &mut String, has_query: &mut bool, key: &str, value: &str) {
    path.push(if *has_query { '&' } else { '?' });
    *has_query = true;
    path.push_str(key);
    path.push('=');
    path.push_str(&percent_encode(value));
}

fn percent_encode(value: &str) -> String {
    const HEX: &[u8; 16] = b"0123456789ABCDEF";
    let mut encoded = String::with_capacity(value.len());

    for byte in value.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
            encoded.push(byte as char);
        } else {
            encoded.push('%');
            encoded.push(HEX[(byte >> 4) as usize] as char);
            encoded.push(HEX[(byte & 0x0f) as usize] as char);
        }
    }

    encoded
}

fn async_options(options: Option<AsyncSubmitOptions>) -> AsyncSubmitOptions {
    options
        .unwrap_or_else(AsyncSubmitOptions::enabled)
        .ensure_async()
}

fn ingest_result_from_job(job: LibraryIngestJobInfo) -> Result<LibraryIngestResult> {
    let response = job.response.ok_or_else(|| {
        OriginError::api(
            0,
            format!("library ingest job {} completed without a response", job.id),
        )
    })?;
    Ok(serde_json::from_value(response)?)
}

fn build_image_request(prompt: String, options: ImageOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::Image,
        prompt: Some(prompt),
        model: options.model,
        input_file: options.input,
        provider: options.provider,
        size: options.size,
        transparent: options.transparent,
        reference_images: options.reference_images,
        edit_mode: options.edit_mode,
        session_id: options.session_id,
        params: merge_params(
            options.params,
            [
                option_entry("quality", options.quality),
                option_entry("background", options.background),
                option_entry("output_format", options.output_format),
                option_entry("n", options.n.map(Value::from)),
                option_entry("preset", options.preset),
                option_entry("mask", options.mask),
                option_entry("mask_url", options.mask_url),
            ],
        ),
    }
}

fn build_generated_asset_request(
    asset_type: AssetType,
    prompt: String,
    options: GeneratedAssetOptions,
) -> GenerateRequest {
    let preset_hint = match asset_type {
        AssetType::Skybox => Some("skybox_equirect"),
        AssetType::Decal => Some("decal_transparent"),
        AssetType::Heightmap => Some("heightmap_grayscale"),
        _ => None,
    };

    let params = merge_params(
        options.params,
        [
            option_entry("preset_hint", preset_hint.map(Value::from)),
            option_entry("quality", options.quality),
            option_entry("background", options.background),
            option_entry("output_format", options.output_format),
            option_entry("n", options.n.map(Value::from)),
            option_entry("preset", options.preset),
        ],
    );

    GenerateRequest {
        asset_type,
        prompt: Some(prompt),
        model: options.model,
        input_file: options.input,
        provider: options.provider,
        size: options.size,
        transparent: options.transparent,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params,
    }
}

fn pack_only_response(asset_type: &str) -> PackOnlyResponse {
    PackOnlyResponse {
        ok: false,
        pack_only: true,
        asset_type: asset_type.to_string(),
        message: "use library packs".to_string(),
    }
}

fn build_video_request(prompt: String, options: VideoOptions) -> GenerateRequest {
    let mut params = options.params;
    if let Some(ratio) = &options.ratio {
        if let Value::Object(ref mut map) = params {
            map.insert("ratio".to_string(), Value::String(ratio.clone()));
        } else {
            params = json!({ "ratio": ratio });
        }
    }

    GenerateRequest {
        asset_type: AssetType::Video,
        prompt: Some(prompt),
        model: options.model,
        input_file: None,
        provider: options.provider,
        size: options.size,
        transparent: None,
        reference_images: options.reference_images,
        edit_mode: None,
        session_id: None,
        params,
    }
}

fn build_audio_request(prompt: String, options: AudioOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::Audio,
        prompt: Some(prompt),
        model: options.model,
        input_file: None,
        provider: options.provider,
        size: None,
        transparent: None,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params: merge_params(
            options.params,
            [
                option_entry("audio_type", options.audio_type),
                option_entry("duration_seconds", options.duration.map(Value::from)),
            ],
        ),
    }
}

fn build_tts_request(prompt: String, options: TtsOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::Tts,
        prompt: Some(prompt),
        model: options.model,
        input_file: None,
        provider: options.provider,
        size: None,
        transparent: None,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params: merge_params(
            options.params,
            [
                option_entry("voice", options.voice),
                option_entry("voice_id", options.voice_id),
                option_entry(
                    "context",
                    options.context.or_else(|| options.instructions.clone()),
                ),
                option_entry("style", options.style),
                option_entry("language_type", options.language),
                option_entry("instructions", options.instructions),
            ],
        ),
    }
}

fn build_music_request(prompt: String, options: MusicOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::Music,
        prompt: Some(prompt),
        model: options.model,
        input_file: None,
        provider: options.provider,
        size: None,
        transparent: None,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params: merge_params(
            options.params,
            [
                option_entry("duration_seconds", options.duration.map(Value::from)),
                bool_entry("force_instrumental", options.force_instrumental),
                option_entry("output_format", options.output_format),
            ],
        ),
    }
}

fn build_model3d_request(prompt: String, options: Model3dOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::Model3d,
        prompt: Some(prompt),
        model: options.model,
        input_file: options.input,
        provider: options.provider,
        size: None,
        transparent: None,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params: merge_params(
            options.params,
            [
                option_entry("ai_model", options.ai_model),
                option_entry("target_polycount", options.polycount.map(Value::from)),
                bool_entry("pbr", options.pbr),
                bool_entry("hd_texture", options.hd_texture),
                bool_entry("auto_size", options.auto_size),
                option_entry("pose_mode", options.pose_mode),
                option_entry("texture_prompt", options.texture_prompt),
                option_entry("format", options.format),
                option_entry(
                    "images",
                    (!options.images.is_empty()).then(|| {
                        Value::Array(options.images.into_iter().map(Value::from).collect())
                    }),
                ),
            ],
        ),
    }
}

fn build_character_request(prompt: String, options: CharacterOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::Character,
        prompt: Some(prompt),
        model: options.model,
        input_file: options.input,
        provider: options.provider,
        size: None,
        transparent: None,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params: merge_params(
            options.params,
            [
                option_entry("format", options.format),
                option_entry("target_polycount", options.polycount.map(Value::from)),
                bool_entry("pbr", options.pbr),
                bool_entry("hd_texture", options.hd_texture),
                option_entry("pose_mode", options.pose_mode),
                option_entry("ai_model", options.ai_model),
                bool_entry("auto_size", options.auto_size),
                option_entry("texture_prompt", options.texture_prompt),
                option_entry(
                    "images",
                    (!options.images.is_empty()).then(|| {
                        Value::Array(options.images.into_iter().map(Value::from).collect())
                    }),
                ),
            ],
        ),
    }
}

fn build_prop_request(prompt: String, options: PropOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::Prop,
        prompt: Some(prompt),
        model: options.model,
        input_file: options.input,
        provider: options.provider,
        size: None,
        transparent: None,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params: merge_params(
            options.params,
            [
                option_entry("format", options.format),
                option_entry("target_polycount", options.polycount.map(Value::from)),
                bool_entry("pbr", options.pbr),
                bool_entry("hd_texture", options.hd_texture),
                option_entry("ai_model", options.ai_model),
                bool_entry("auto_size", options.auto_size),
                option_entry("texture_prompt", options.texture_prompt),
                option_entry("symmetry_mode", options.symmetry_mode),
                option_entry(
                    "images",
                    (!options.images.is_empty()).then(|| {
                        Value::Array(options.images.into_iter().map(Value::from).collect())
                    }),
                ),
            ],
        ),
    }
}

fn build_sprite_request(prompt: String, options: SpriteOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::Sprite,
        prompt: Some(prompt),
        model: options.model,
        input_file: options.input,
        provider: options.provider,
        size: None,
        transparent: None,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params: merge_params(
            options.params,
            [
                option_entry(
                    "animation_type",
                    Some(Value::String(
                        options.animation_type.unwrap_or_else(|| "walk".to_string()),
                    )),
                ),
                option_entry(
                    "direction",
                    Some(Value::String(
                        options.direction.unwrap_or_else(|| "right".to_string()),
                    )),
                ),
                option_entry("duration", Some(Value::from(options.duration.unwrap_or(2)))),
                option_entry(
                    "output_format",
                    Some(Value::String(
                        options
                            .output_format
                            .unwrap_or_else(|| "spritesheet".to_string()),
                    )),
                ),
                option_entry("fps", options.fps.map(Value::from)),
                option_entry("style", options.style),
            ],
        ),
    }
}

fn build_world_request(prompt: String, options: WorldOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::World,
        prompt: Some(prompt),
        model: options.model,
        input_file: options.input.clone().or_else(|| options.image_url.clone()),
        provider: options.provider,
        size: None,
        transparent: None,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params: merge_params(
            options.params,
            [
                option_entry("display_name", options.display_name),
                option_entry("image_url", options.image_url),
                option_entry("panorama_url", options.panorama_url),
                option_entry("video_url", options.video_url),
                option_entry(
                    "multi_image_url",
                    (!options.multi_image_url.is_empty()).then(|| {
                        Value::Array(
                            options
                                .multi_image_url
                                .into_iter()
                                .map(Value::from)
                                .collect(),
                        )
                    }),
                ),
                option_entry("quality", options.quality),
            ],
        ),
    }
}

fn build_text_request(prompt: String, options: TextOptions) -> GenerateRequest {
    GenerateRequest {
        asset_type: AssetType::Text,
        prompt: Some(prompt),
        model: options.model,
        input_file: None,
        provider: options.provider,
        size: None,
        transparent: None,
        reference_images: Vec::new(),
        edit_mode: None,
        session_id: None,
        params: merge_params(
            options.params,
            [option_entry(
                "max_tokens",
                options.max_tokens.map(Value::from),
            )],
        ),
    }
}

fn normalize_job_event(kind: &str, data: &str) -> JobEvent {
    let raw: Value = serde_json::from_str(data).unwrap_or_else(|_| Value::String(data.to_string()));
    let record = raw.as_object();
    let status = record
        .and_then(|map| map.get("status"))
        .and_then(Value::as_str)
        .unwrap_or("unknown")
        .to_string();
    let job_id = record
        .and_then(|map| map.get("job_id").or_else(|| map.get("id")))
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();

    JobEvent {
        kind: match kind {
            "snapshot" | "progress" | "update" => kind.to_string(),
            _ => "update".to_string(),
        },
        job_id,
        status,
        progress: record.and_then(extract_progress),
        output_url: record
            .and_then(|map| map.get("output_url"))
            .and_then(Value::as_str)
            .map(String::from),
        error_message: record
            .and_then(|map| map.get("error_message"))
            .and_then(Value::as_str)
            .map(String::from),
        timestamp: record
            .and_then(|map| map.get("timestamp"))
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string(),
        raw,
    }
}

fn extract_progress(map: &Map<String, Value>) -> Option<JobProgress> {
    let nested = map.get("progress").and_then(|value| {
        if value.is_object() {
            serde_json::from_value::<JobProgress>(value.clone()).ok()
        } else {
            None
        }
    });
    let mut progress = nested.unwrap_or_default();

    if progress.progress.is_none() {
        progress.progress = map.get("progress").and_then(Value::as_f64);
    }
    if progress.current_step.is_none() {
        progress.current_step = map
            .get("current_step")
            .and_then(Value::as_str)
            .map(String::from);
    }
    if progress.step_index.is_none() {
        progress.step_index = map.get("step_index").and_then(Value::as_i64);
    }
    if progress.step_count.is_none() {
        progress.step_count = map.get("step_count").and_then(Value::as_i64);
    }
    if progress.message.is_none() {
        progress.message = map.get("message").and_then(Value::as_str).map(String::from);
    }
    if progress.details.is_null() {
        progress.details = map.get("details").cloned().unwrap_or(Value::Null);
    }

    (progress.progress.is_some()
        || progress.current_step.is_some()
        || progress.step_index.is_some()
        || progress.step_count.is_some()
        || progress.message.is_some()
        || !progress.details.is_null())
    .then_some(progress)
}

fn is_terminal_status(status: &str) -> bool {
    matches!(status, "completed" | "failed" | "cancelled")
}

fn terminal_job_result(job: JobStatusResponse) -> Result<JobStatusResponse> {
    match job.status.as_str() {
        "cancelled" => Err(OriginError::job_cancelled(
            job.error_message
                .clone()
                .unwrap_or_else(|| "Job cancelled".to_string()),
        )),
        "failed" => Err(OriginError::api_raw(
            500,
            Some("JOB_FAILED".to_string()),
            job.error_message
                .clone()
                .unwrap_or_else(|| "Job failed".to_string()),
            None,
        )),
        _ => Ok(job),
    }
}
