// SPDX-License-Identifier: MIT
// Copyright (c) 2025-2026 SenWeaverCoding
// Licensed under the MIT License.
use crate::config::LinkedInImageConfig;
use anyhow::Context;
use reqwest::Method;
use reqwest::header::{HeaderMap, HeaderValue};
use serde_json::json;
use std::path::{Path, PathBuf};

const LINKEDIN_API_BASE: &str = "https://api.linkedin.com";
const LINKEDIN_OAUTH_TOKEN_URL: &str = "https://www.linkedin.com/oauth/v2/accessToken";
const LINKEDIN_REQUEST_TIMEOUT_SECS: u64 = 30;
const LINKEDIN_CONNECT_TIMEOUT_SECS: u64 = 10;

pub struct LinkedInClient {
    workspace_dir: PathBuf,
    api_version: String,
}

#[derive(Debug)]
pub struct LinkedInCredentials {
    pub client_id: String,
    pub client_secret: String,
    pub access_token: String,
    pub refresh_token: Option<String>,
    pub person_id: String,
}

#[derive(Debug, serde::Serialize)]
pub struct PostSummary {
    pub id: String,
    pub text: String,
    pub created_at: String,
    pub visibility: String,
}

#[derive(Debug, serde::Serialize)]
pub struct ProfileInfo {
    pub id: String,
    pub name: String,
    pub headline: String,
}

#[derive(Debug, serde::Serialize)]
pub struct EngagementSummary {
    pub likes: u64,
    pub comments: u64,
    pub shares: u64,
}

impl LinkedInClient {
    pub fn new(workspace_dir: PathBuf, api_version: String) -> Self {
        Self {
            workspace_dir,
            api_version,
        }
    }

    fn parse_env_value(raw: &str) -> String {
        let raw = raw.trim();

        let unquoted = if raw.len() >= 2
            && ((raw.starts_with('"') && raw.ends_with('"'))
                || (raw.starts_with('\'') && raw.ends_with('\'')))
        {
            &raw[1..raw.len() - 1]
        } else {
            raw
        };

        unquoted.split_once(" #").map_or_else(
            || unquoted.trim().to_string(),
            |(value, _)| value.trim().to_string(),
        )
    }

    pub async fn get_credentials(&self) -> anyhow::Result<LinkedInCredentials> {
        let env_path = self.workspace_dir.join(".env");
        let content = tokio::fs::read_to_string(&env_path)
            .await
            .with_context(|| format!("Failed to read {}", env_path.display()))?;

        let mut client_id = None;
        let mut client_secret = None;
        let mut access_token = None;
        let mut refresh_token = None;
        let mut person_id = None;

        for line in content.lines() {
            let line = line.trim();
            if line.starts_with('#') || line.is_empty() {
                continue;
            }
            let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
            if let Some((key, value)) = line.split_once('=') {
                let key = key.trim();
                let value = Self::parse_env_value(value);

                match key {
                    "LINKEDIN_CLIENT_ID" => client_id = Some(value),
                    "LINKEDIN_CLIENT_SECRET" => client_secret = Some(value),
                    "LINKEDIN_ACCESS_TOKEN" => access_token = Some(value),
                    "LINKEDIN_REFRESH_TOKEN" => {
                        if !value.is_empty() {
                            refresh_token = Some(value);
                        }
                    }
                    "LINKEDIN_PERSON_ID" => person_id = Some(value),
                    _ => {}
                }
            }
        }

        let client_id =
            client_id.ok_or_else(|| anyhow::anyhow!("LINKEDIN_CLIENT_ID not found in .env"))?;
        let client_secret = client_secret
            .ok_or_else(|| anyhow::anyhow!("LINKEDIN_CLIENT_SECRET not found in .env"))?;
        let access_token = access_token
            .ok_or_else(|| anyhow::anyhow!("LINKEDIN_ACCESS_TOKEN not found in .env"))?;
        let person_id =
            person_id.ok_or_else(|| anyhow::anyhow!("LINKEDIN_PERSON_ID not found in .env"))?;

        Ok(LinkedInCredentials {
            client_id,
            client_secret,
            access_token,
            refresh_token,
            person_id,
        })
    }

    fn client() -> reqwest::Client {
        crate::services::require_services()
            .proxy_runtime()
            .build_client_with_timeouts(
                "tool.linkedin",
                LINKEDIN_REQUEST_TIMEOUT_SECS,
                LINKEDIN_CONNECT_TIMEOUT_SECS,
            )
    }

    fn api_headers(&self, token: &str) -> HeaderMap {
        let mut headers = HeaderMap::new();
        let bearer = format!("Bearer {}", token);
        headers.insert(
            reqwest::header::AUTHORIZATION,
            HeaderValue::from_str(&bearer)
                .unwrap_or_else(|_| HeaderValue::from_static("Bearer INVALID")),
        );
        headers.insert(
            "LinkedIn-Version",
            HeaderValue::from_str(&self.api_version)
                .unwrap_or_else(|_| HeaderValue::from_static("202501")),
        );
        headers.insert(
            "X-Restli-Protocol-Version",
            HeaderValue::from_static("2.0.0"),
        );
        headers
    }

    async fn api_request(
        &self,
        method: Method,
        url: &str,
        token: &str,
        body: Option<serde_json::Value>,
    ) -> anyhow::Result<reqwest::Response> {
        let client = Self::client();
        let headers = self.api_headers(token);

        let mut req = client.request(method.clone(), url).headers(headers);
        if let Some(ref json_body) = body {
            req = req.json(json_body);
        }

        let response = req.send().await.context("LinkedIn API request failed")?;

        if response.status() == reqwest::StatusCode::UNAUTHORIZED {

            let creds = self.get_credentials().await?;
            let new_token = self.refresh_token(&creds).await?;
            self.update_env_token(&new_token).await?;

            let retry_headers = self.api_headers(&new_token);
            let mut retry_req = Self::client().request(method, url).headers(retry_headers);
            if let Some(json_body) = body {
                retry_req = retry_req.json(&json_body);
            }

            let retry_response = retry_req
                .send()
                .await
                .context("LinkedIn API retry request failed")?;

            return Ok(retry_response);
        }

        Ok(response)
    }

    pub async fn create_post(
        &self,
        text: &str,
        visibility: &str,
        article_url: Option<&str>,
        article_title: Option<&str>,
        scheduled_at: Option<&str>,
    ) -> anyhow::Result<String> {
        let creds = self.get_credentials().await?;
        let author_urn = format!("urn:li:person:{}", creds.person_id);

        let lifecycle = if scheduled_at.is_some() {
            "DRAFT"
        } else {
            "PUBLISHED"
        };

        let mut body = json!({
            "author": author_urn,
            "lifecycleState": lifecycle,
            "visibility": visibility,
            "commentary": text,
            "distribution": {
                "feedDistribution": "MAIN_FEED",
                "targetEntities": [],
                "thirdPartyDistributionChannels": []
            }
        });

        if let Some(ts) = scheduled_at {
            if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
                let epoch_ms = dt.timestamp_millis();
                if let Some(obj) = body.as_object_mut() {
                    obj.insert(
                        "scheduledPublishOptions".to_string(),
                        json!({ "scheduledPublishTime": epoch_ms }),
                    );
                }
                body["lifecycleState"] = json!("DRAFT");
            }
        }

        if let Some(url) = article_url {
            let mut article = json!({
                "source": url,
                "title": article_title.unwrap_or(""),
            });
            if article_title.is_none() || article_title.map_or(false, |t| t.is_empty()) {
                if let Some(obj) = article.as_object_mut() {
                    obj.remove("title");
                }
            }
            if let Some(obj) = body.as_object_mut() {
                obj.insert(
                    "content".to_string(),
                    json!({
                        "article": {
                            "source": url,
                            "title": article_title.unwrap_or("")
                        }
                    }),
                );
            }
        }

        let url = format!("{}/rest/posts", LINKEDIN_API_BASE);
        let response = self
            .api_request(Method::POST, &url, &creds.access_token, Some(body))
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body_text = response.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn create_post failed ({}): {}", status, body_text);
        }

        let post_urn = response
            .headers()
            .get("x-restli-id")
            .and_then(|v| v.to_str().ok())
            .map(String::from)
            .unwrap_or_default();

        Ok(post_urn)
    }

    pub async fn list_posts(&self, count: usize) -> anyhow::Result<Vec<PostSummary>> {
        let creds = self.get_credentials().await?;
        let author_urn = format!("urn:li:person:{}", creds.person_id);
        let url = format!(
            "{}/rest/posts?author={}&q=author&count={}",
            LINKEDIN_API_BASE, author_urn, count
        );

        let response = self
            .api_request(Method::GET, &url, &creds.access_token, None)
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body_text = response.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn list_posts failed ({}): {}", status, body_text);
        }

        let json: serde_json::Value = response
            .json()
            .await
            .context("Failed to parse list_posts response")?;

        let elements = json
            .get("elements")
            .and_then(|e| e.as_array())
            .cloned()
            .unwrap_or_default();

        let posts = elements
            .iter()
            .map(|el| PostSummary {
                id: el
                    .get("id")
                    .and_then(|v| v.as_str())
                    .unwrap_or_default()
                    .to_string(),
                text: el
                    .get("commentary")
                    .and_then(|v| v.as_str())
                    .unwrap_or_default()
                    .to_string(),
                created_at: el
                    .get("createdAt")
                    .and_then(|v| v.as_u64())
                    .map(|ts| ts.to_string())
                    .unwrap_or_default(),
                visibility: el
                    .get("visibility")
                    .and_then(|v| v.as_str())
                    .unwrap_or_default()
                    .to_string(),
            })
            .collect();

        Ok(posts)
    }

    pub async fn add_comment(&self, post_id: &str, text: &str) -> anyhow::Result<String> {
        let creds = self.get_credentials().await?;
        let actor_urn = format!("urn:li:person:{}", creds.person_id);
        let url = format!(
            "{}/rest/socialActions/{}/comments",
            LINKEDIN_API_BASE, post_id
        );

        let body = json!({
            "actor": actor_urn,
            "message": {
                "text": text
            }
        });

        let response = self
            .api_request(Method::POST, &url, &creds.access_token, Some(body))
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body_text = response.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn add_comment failed ({}): {}", status, body_text);
        }

        let json: serde_json::Value = response
            .json()
            .await
            .context("Failed to parse add_comment response")?;

        let comment_id = json
            .get("id")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_string();

        Ok(comment_id)
    }

    pub async fn add_reaction(&self, post_id: &str, reaction_type: &str) -> anyhow::Result<()> {
        let creds = self.get_credentials().await?;
        let actor_urn = format!("urn:li:person:{}", creds.person_id);
        let url = format!("{}/rest/reactions?actor={}", LINKEDIN_API_BASE, actor_urn);

        let body = json!({
            "reactionType": reaction_type,
            "object": post_id
        });

        let response = self
            .api_request(Method::POST, &url, &creds.access_token, Some(body))
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body_text = response.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn add_reaction failed ({}): {}", status, body_text);
        }

        Ok(())
    }

    pub async fn delete_post(&self, post_id: &str) -> anyhow::Result<()> {
        let creds = self.get_credentials().await?;
        let url = format!("{}/rest/posts/{}", LINKEDIN_API_BASE, post_id);

        let response = self
            .api_request(Method::DELETE, &url, &creds.access_token, None)
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body_text = response.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn delete_post failed ({}): {}", status, body_text);
        }

        Ok(())
    }

    pub async fn get_engagement(&self, post_id: &str) -> anyhow::Result<EngagementSummary> {
        let creds = self.get_credentials().await?;
        let url = format!("{}/rest/socialActions/{}", LINKEDIN_API_BASE, post_id);

        let response = self
            .api_request(Method::GET, &url, &creds.access_token, None)
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body_text = response.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn get_engagement failed ({}): {}", status, body_text);
        }

        let json: serde_json::Value = response
            .json()
            .await
            .context("Failed to parse get_engagement response")?;

        let likes = json
            .get("likesSummary")
            .and_then(|v| v.get("totalLikes"))
            .and_then(|v| v.as_u64())
            .unwrap_or(0);

        let comments = json
            .get("commentsSummary")
            .and_then(|v| v.get("totalFirstLevelComments"))
            .and_then(|v| v.as_u64())
            .unwrap_or(0);

        let shares = json
            .get("sharesSummary")
            .and_then(|v| v.get("totalShares"))
            .and_then(|v| v.as_u64())
            .unwrap_or(0);

        Ok(EngagementSummary {
            likes,
            comments,
            shares,
        })
    }

    pub async fn get_profile(&self) -> anyhow::Result<ProfileInfo> {
        let creds = self.get_credentials().await?;
        let url = format!("{}/rest/me", LINKEDIN_API_BASE);

        let response = self
            .api_request(Method::GET, &url, &creds.access_token, None)
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body_text = response.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn get_profile failed ({}): {}", status, body_text);
        }

        let json: serde_json::Value = response
            .json()
            .await
            .context("Failed to parse get_profile response")?;

        let id = json
            .get("id")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_string();

        let first_name = json
            .get("localizedFirstName")
            .and_then(|v| v.as_str())
            .unwrap_or_default();

        let last_name = json
            .get("localizedLastName")
            .and_then(|v| v.as_str())
            .unwrap_or_default();

        let name = format!("{} {}", first_name, last_name).trim().to_string();

        let headline = json
            .get("localizedHeadline")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_string();

        Ok(ProfileInfo { id, name, headline })
    }

    async fn refresh_token(&self, creds: &LinkedInCredentials) -> anyhow::Result<String> {
        let refresh = creds
            .refresh_token
            .as_deref()
            .filter(|t| !t.is_empty())
            .ok_or_else(|| anyhow::anyhow!("No refresh token available"))?;

        let client = Self::client();
        let response = client
            .post(LINKEDIN_OAUTH_TOKEN_URL)
            .form(&[
                ("grant_type", "refresh_token"),
                ("refresh_token", refresh),
                ("client_id", &creds.client_id),
                ("client_secret", &creds.client_secret),
            ])
            .send()
            .await
            .context("LinkedIn token refresh request failed")?;

        let status = response.status();
        if !status.is_success() {
            let body_text = response.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn token refresh failed ({}): {}", status, body_text);
        }

        let json: serde_json::Value = response
            .json()
            .await
            .context("Failed to parse token refresh response")?;

        let new_token = json
            .get("access_token")
            .and_then(|v| v.as_str())
            .map(String::from)
            .ok_or_else(|| anyhow::anyhow!("Token refresh response missing access_token field"))?;

        Ok(new_token)
    }

    pub async fn upload_image(
        &self,
        image_bytes: &[u8],
        token: &str,
        person_id: &str,
    ) -> anyhow::Result<String> {
        let owner_urn = format!("urn:li:person:{person_id}");

        let register_body = json!({
            "initializeUploadRequest": {
                "owner": owner_urn
            }
        });
        let register_url = format!("{LINKEDIN_API_BASE}/rest/images?action=initializeUpload");
        let register_resp = self
            .api_request(Method::POST, &register_url, token, Some(register_body))
            .await?;

        let status = register_resp.status();
        if !status.is_success() {
            let body_text = register_resp.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn image register failed ({status}): {body_text}");
        }

        let register_json: serde_json::Value = register_resp
            .json()
            .await
            .context("Failed to parse image register response")?;

        let upload_url = register_json
            .pointer("/value/uploadUrl")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("Missing uploadUrl in register response"))?
            .to_string();

        let image_urn = register_json
            .pointer("/value/image")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("Missing image URN in register response"))?
            .to_string();

        let client = Self::client();
        let mut upload_headers = HeaderMap::new();
        upload_headers.insert(
            reqwest::header::AUTHORIZATION,
            HeaderValue::from_str(&format!("Bearer {token}"))
                .map_err(|e| anyhow::anyhow!("invalid bearer header: {e}"))?,
        );

        let upload_resp = client
            .put(&upload_url)
            .headers(upload_headers)
            .header("Content-Type", "image/png")
            .body(image_bytes.to_vec())
            .send()
            .await
            .context("LinkedIn image upload failed")?;

        let upload_status = upload_resp.status();
        if !upload_status.is_success() {
            let body_text = upload_resp.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn image upload failed ({upload_status}): {body_text}");
        }

        Ok(image_urn)
    }

    pub async fn create_post_with_image(
        &self,
        text: &str,
        visibility: &str,
        image_urn: &str,
        scheduled_at: Option<&str>,
    ) -> anyhow::Result<String> {
        let creds = self.get_credentials().await?;
        let author_urn = format!("urn:li:person:{}", creds.person_id);

        let lifecycle = if scheduled_at.is_some() {
            "DRAFT"
        } else {
            "PUBLISHED"
        };

        let mut body = json!({
            "author": author_urn,
            "lifecycleState": lifecycle,
            "visibility": visibility,
            "commentary": text,
            "distribution": {
                "feedDistribution": "MAIN_FEED",
                "targetEntities": [],
                "thirdPartyDistributionChannels": []
            },
            "content": {
                "media": {
                    "id": image_urn
                }
            }
        });

        if let Some(ts) = scheduled_at {
            if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
                let epoch_ms = dt.timestamp_millis();
                if let Some(obj) = body.as_object_mut() {
                    obj.insert(
                        "scheduledPublishOptions".to_string(),
                        json!({ "scheduledPublishTime": epoch_ms }),
                    );
                }
            }
        }

        let url = format!("{LINKEDIN_API_BASE}/rest/posts");
        let response = self
            .api_request(Method::POST, &url, &creds.access_token, Some(body))
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body_text = response.text().await.unwrap_or_default();
            anyhow::bail!("LinkedIn create_post_with_image failed ({status}): {body_text}");
        }

        let post_urn = response
            .headers()
            .get("x-restli-id")
            .and_then(|v| v.to_str().ok())
            .map(String::from)
            .unwrap_or_default();

        Ok(post_urn)
    }

    async fn update_env_token(&self, new_token: &str) -> anyhow::Result<()> {
        let env_path = self.workspace_dir.join(".env");
        let content = tokio::fs::read_to_string(&env_path)
            .await
            .with_context(|| format!("Failed to read {}", env_path.display()))?;

        let mut updated_lines: Vec<String> = Vec::new();
        let mut found = false;

        for line in content.lines() {
            let trimmed = line.trim();

            let is_token_line = if trimmed.starts_with('#') || trimmed.is_empty() {
                false
            } else {
                let check = trimmed
                    .strip_prefix("export ")
                    .map(str::trim)
                    .unwrap_or(trimmed);
                check
                    .split_once('=')
                    .map_or(false, |(key, _)| key.trim() == "LINKEDIN_ACCESS_TOKEN")
            };

            if is_token_line {

                let has_export = trimmed.starts_with("export ");
                let after_key = trimmed.strip_prefix("export ").unwrap_or(trimmed).trim();
                let (_key, old_val) = after_key
                    .split_once('=')
                    .unwrap_or(("LINKEDIN_ACCESS_TOKEN", ""));
                let old_val = old_val.trim();

                let new_val = if old_val.starts_with('"') {
                    format!("\"{}\"", new_token)
                } else if old_val.starts_with('\'') {
                    format!("'{}'", new_token)
                } else {
                    new_token.to_string()
                };

                let new_line = if has_export {
                    format!("export LINKEDIN_ACCESS_TOKEN={}", new_val)
                } else {
                    format!("LINKEDIN_ACCESS_TOKEN={}", new_val)
                };

                updated_lines.push(new_line);
                found = true;
            } else {
                updated_lines.push(line.to_string());
            }
        }

        if !found {
            anyhow::bail!("LINKEDIN_ACCESS_TOKEN not found in .env for update");
        }

        let mut output = updated_lines.join("\n");
        if content.ends_with('\n') {
            output.push('\n');
        }

        tokio::fs::write(&env_path, &output)
            .await
            .with_context(|| format!("Failed to write {}", env_path.display()))?;

        Ok(())
    }
}

pub struct ImageGenerator {
    config: LinkedInImageConfig,
    workspace_dir: PathBuf,
}

impl ImageGenerator {
    pub fn new(config: LinkedInImageConfig, workspace_dir: PathBuf) -> Self {
        Self {
            config,
            workspace_dir,
        }
    }

    pub async fn generate(&self, prompt: &str) -> anyhow::Result<PathBuf> {
        let image_dir = self.workspace_dir.join(&self.config.temp_dir);
        tokio::fs::create_dir_all(&image_dir).await?;

        let timestamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let base_name = format!("post_{timestamp}");

        for provider_name in &self.config.providers {
            let result = match provider_name.as_str() {
                "stability" => self.try_stability(prompt, &image_dir, &base_name).await,
                "imagen" => self.try_imagen(prompt, &image_dir, &base_name).await,
                "dalle" => self.try_dalle(prompt, &image_dir, &base_name).await,
                "flux" => self.try_flux(prompt, &image_dir, &base_name).await,
                other => {
                    tracing::warn!("Unknown image provider '{other}', skipping");
                    continue;
                }
            };

            match result {
                Ok(path) => {
                    tracing::info!("Image generated via {provider_name}: {}", path.display());
                    return Ok(path);
                }
                Err(e) => {
                    tracing::warn!("Image provider '{provider_name}' failed: {e}");
                }
            }
        }

        if self.config.fallback_card {
            let svg_path = image_dir.join(format!("{base_name}.svg"));
            let svg_content = Self::generate_fallback_card(prompt, &self.config.card_accent_color);
            tokio::fs::write(&svg_path, &svg_content).await?;
            tracing::info!("Fallback SVG card generated: {}", svg_path.display());
            return Ok(svg_path);
        }

        anyhow::bail!("All image generation providers failed and fallback_card is disabled")
    }

    async fn read_env_var(workspace_dir: &Path, var_name: &str) -> anyhow::Result<String> {
        let env_path = workspace_dir.join(".env");
        let content = tokio::fs::read_to_string(&env_path)
            .await
            .with_context(|| format!("Failed to read {}", env_path.display()))?;

        for line in content.lines() {
            let line = line.trim();
            if line.starts_with('#') || line.is_empty() {
                continue;
            }
            let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
            if let Some((key, value)) = line.split_once('=') {
                if key.trim() == var_name {
                    let val = LinkedInClient::parse_env_value(value);
                    if !val.is_empty() {
                        return Ok(val);
                    }
                }
            }
        }

        anyhow::bail!("{var_name} not found or empty in .env")
    }

    fn http_client() -> reqwest::Client {
        crate::services::require_services()
            .proxy_runtime()
            .build_client_with_timeouts("tool.linkedin.image", 60, 10)
    }

    async fn try_stability(
        &self,
        prompt: &str,
        output_dir: &Path,
        base_name: &str,
    ) -> anyhow::Result<PathBuf> {
        let api_key =
            Self::read_env_var(&self.workspace_dir, &self.config.stability.api_key_env).await?;

        let client = Self::http_client();
        let url = format!(
            "https://api.stability.ai/v1/generation/{}/text-to-image",
            self.config.stability.model
        );

        let body = json!({
            "text_prompts": [{"text": prompt, "weight": 1.0}],
            "cfg_scale": 7,
            "height": 1024,
            "width": 1024,
            "samples": 1,
            "steps": 30
        });

        let resp = client
            .post(&url)
            .header("Authorization", format!("Bearer {api_key}"))
            .header("Content-Type", "application/json")
            .header("Accept", "application/json")
            .json(&body)
            .send()
            .await
            .context("Stability AI request failed")?;

        let status = resp.status();
        if !status.is_success() {
            let body_text = resp.text().await.unwrap_or_default();
            anyhow::bail!("Stability AI failed ({status}): {body_text}");
        }

        let json: serde_json::Value = resp.json().await?;
        let b64 = json
            .pointer("/artifacts/0/base64")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("No image data in Stability response"))?;

        let bytes = base64_decode(b64)?;
        let path = output_dir.join(format!("{base_name}_stability.png"));
        tokio::fs::write(&path, &bytes).await?;
        Ok(path)
    }

    async fn try_imagen(
        &self,
        prompt: &str,
        output_dir: &Path,
        base_name: &str,
    ) -> anyhow::Result<PathBuf> {
        let api_key =
            Self::read_env_var(&self.workspace_dir, &self.config.imagen.api_key_env).await?;
        let project_id =
            Self::read_env_var(&self.workspace_dir, &self.config.imagen.project_id_env).await?;

        let client = Self::http_client();
        let url = format!(
            "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models/imagen-3.0-generate-001:predict",
            self.config.imagen.region, project_id, self.config.imagen.region
        );

        let body = json!({
            "instances": [{"prompt": prompt}],
            "parameters": {
                "sampleCount": 1,
                "aspectRatio": "1:1"
            }
        });

        let resp = client
            .post(&url)
            .header("Authorization", format!("Bearer {api_key}"))
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .await
            .context("Imagen request failed")?;

        let status = resp.status();
        if !status.is_success() {
            let body_text = resp.text().await.unwrap_or_default();
            anyhow::bail!("Imagen failed ({status}): {body_text}");
        }

        let json: serde_json::Value = resp.json().await?;
        let b64 = json
            .pointer("/predictions/0/bytesBase64Encoded")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("No image data in Imagen response"))?;

        let bytes = base64_decode(b64)?;
        let path = output_dir.join(format!("{base_name}_imagen.png"));
        tokio::fs::write(&path, &bytes).await?;
        Ok(path)
    }

    async fn try_dalle(
        &self,
        prompt: &str,
        output_dir: &Path,
        base_name: &str,
    ) -> anyhow::Result<PathBuf> {
        let api_key =
            Self::read_env_var(&self.workspace_dir, &self.config.dalle.api_key_env).await?;

        let client = Self::http_client();
        let url = "https://api.openai.com/v1/images/generations";

        let body = json!({
            "model": self.config.dalle.model,
            "prompt": prompt,
            "n": 1,
            "size": self.config.dalle.size,
            "response_format": "b64_json"
        });

        let resp = client
            .post(url)
            .header("Authorization", format!("Bearer {api_key}"))
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .await
            .context("DALL-E request failed")?;

        let status = resp.status();
        if !status.is_success() {
            let body_text = resp.text().await.unwrap_or_default();
            anyhow::bail!("DALL-E failed ({status}): {body_text}");
        }

        let json: serde_json::Value = resp.json().await?;
        let b64 = json
            .pointer("/data/0/b64_json")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("No image data in DALL-E response"))?;

        let bytes = base64_decode(b64)?;
        let path = output_dir.join(format!("{base_name}_dalle.png"));
        tokio::fs::write(&path, &bytes).await?;
        Ok(path)
    }

    async fn try_flux(
        &self,
        prompt: &str,
        output_dir: &Path,
        base_name: &str,
    ) -> anyhow::Result<PathBuf> {
        let api_key =
            Self::read_env_var(&self.workspace_dir, &self.config.flux.api_key_env).await?;

        let client = Self::http_client();
        let url = format!("https://fal.run/{}", self.config.flux.model);

        let body = json!({
            "prompt": prompt,
            "image_size": "square_hd",
            "num_images": 1
        });

        let resp = client
            .post(&url)
            .header("Authorization", format!("Key {api_key}"))
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .await
            .context("Flux request failed")?;

        let status = resp.status();
        if !status.is_success() {
            let body_text = resp.text().await.unwrap_or_default();
            anyhow::bail!("Flux failed ({status}): {body_text}");
        }

        let json: serde_json::Value = resp.json().await?;
        let image_url = json
            .pointer("/images/0/url")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("No image URL in Flux response"))?;

        let img_resp = client.get(image_url).send().await?;
        if !img_resp.status().is_success() {
            anyhow::bail!("Failed to download Flux image from {image_url}");
        }
        let bytes = img_resp.bytes().await?;
        let path = output_dir.join(format!("{base_name}_flux.png"));
        tokio::fs::write(&path, &bytes).await?;
        Ok(path)
    }

    pub fn generate_fallback_card(title: &str, accent_color: &str) -> String {

        let display_title = if title.len() > 80 {
            format!("{}...", crate::util::truncate_str_bytes(title, 77))
        } else {
            title.to_string()
        };

        let lines = word_wrap(&display_title, 35, 3);
        let line_height: i32 = 48;

        #[allow(clippy::cast_possible_truncation)]
        let line_count: i32 = lines.len() as i32;
        let total_text_height = line_count * line_height;
        let start_y = (1024 - total_text_height) / 2 + 24;

        let font = "system-ui, sans-serif";
        let text_elements: String = lines
            .iter()
            .enumerate()
            .map(|(i, line)| {
                #[allow(clippy::cast_possible_truncation)]
                let y = start_y + (i as i32 * line_height);
                format!(
                    "    <text x=\"512\" y=\"{y}\" text-anchor=\"middle\" fill=\"white\" \
                     font-family=\"{font}\" font-size=\"36\" font-weight=\"600\">{}</text>",
                    xml_escape(line)
                )
            })
            .collect::<Vec<_>>()
            .join("\n");

        format!(
            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1024\" height=\"1024\" \
             viewBox=\"0 0 1024 1024\">\n\
             \x20 <defs>\n\
             \x20   <linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n\
             \x20     <stop offset=\"0%\" stop-color=\"{accent_color}\"/>\n\
             \x20     <stop offset=\"100%\" stop-color=\"#1a1a2e\"/>\n\
             \x20   </linearGradient>\n\
             \x20 </defs>\n\
             \x20 <rect width=\"1024\" height=\"1024\" fill=\"url(#bg)\" rx=\"0\"/>\n\
             \x20 <rect x=\"60\" y=\"60\" width=\"904\" height=\"904\" rx=\"24\" \
             fill=\"none\" stroke=\"rgba(255,255,255,0.15)\" stroke-width=\"2\"/>\n\
             {text_elements}\n\
             \x20 <text x=\"512\" y=\"920\" text-anchor=\"middle\" \
             fill=\"rgba(255,255,255,0.5)\" font-family=\"{font}\" \
             font-size=\"18\">SenWeaverCoding</text>\n\
             </svg>"
        )
    }

    pub async fn cleanup(path: &Path) -> anyhow::Result<()> {
        if path.exists() {
            tokio::fs::remove_file(path).await?;
        }
        Ok(())
    }
}

fn base64_decode(input: &str) -> anyhow::Result<Vec<u8>> {
    use base64::Engine;
    base64::engine::general_purpose::STANDARD
        .decode(input)
        .context("Failed to decode base64 image data")
}

fn word_wrap(text: &str, max_width: usize, max_lines: usize) -> Vec<String> {
    let mut lines = Vec::new();
    let mut current_line = String::new();

    for word in text.split_whitespace() {
        if current_line.is_empty() {
            current_line = word.to_string();
        } else if current_line.len() + 1 + word.len() <= max_width {
            current_line.push(' ');
            current_line.push_str(word);
        } else {
            lines.push(current_line);
            current_line = word.to_string();
            if lines.len() >= max_lines {
                break;
            }
        }
    }

    if !current_line.is_empty() && lines.len() < max_lines {
        lines.push(current_line);
    }

    lines
}

fn xml_escape(text: &str) -> String {
    text.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}
