//! Parslee Studio media tools for the general assistant — capabilities beyond
//! CAR's LOCAL models, delivered by the Parslee Studio service. Today:
//! `generate_music` (real generated music, ElevenLabs Music via Studio). A
//! text-only agent (Claude Code, Codex) structurally can't offer this.
//!
//! Same host-side + path-artifact contract as [`MediaTools`](super::media_tools):
//! write a file under the working root and return its PATH, never inline bytes
//! (base64 media would be shredded by the loop's 16 KB observation cap). Studio
//! returns a time-limited download URL; this provider fetches it and persists the
//! bytes under the root inside the tool call, so the artifact never expires out
//! from under the agent. The output path is clamped under the root with the same
//! lexical + canonicalize-and-recheck guard MediaTools uses (this writer runs
//! host-side, sharing the sandbox mount).

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};

use async_trait::async_trait;
use car_engine::ToolExecutor;
use car_parslee::studio::StudioClient;
use serde_json::{json, Value};

use crate::coder::policy::stays_under;

const STUDIO_TOOL_TIER: &str = "full_access";

/// Host-side Studio media generation. Advertised only when a Parslee session
/// exists (the user ran `car auth login`).
pub struct StudioMediaTools {
    studio: Arc<StudioClient>,
    http: reqwest::Client,
    root: PathBuf,
}

impl StudioMediaTools {
    pub fn new(root: PathBuf) -> Self {
        Self {
            studio: Arc::new(StudioClient::new()),
            http: reqwest::Client::new(),
            root,
        }
    }

    /// Cheap, non-network availability check: is a Parslee bearer present? Never
    /// advertise a Studio tool on a host with no Parslee session.
    fn available(&self) -> bool {
        car_auth::access_token().is_some()
    }

    pub fn tool_defs(&self) -> Vec<Value> {
        if !self.available() {
            return Vec::new();
        }
        studio_tool_defs()
    }
}

fn studio_tool_defs() -> Vec<Value> {
    vec![
        json!({
            "name": "generate_music",
            "description": "Generate an original music track from a text prompt via Parslee Studio \
                (ElevenLabs Music). Writes an audio file under the working directory and returns its \
                path — use it for game soundtracks, background music, intros, ambience. Takes ~30s. \
                The result is a file path; embed it (e.g. <audio src>) or read it like any file.",
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": "Describe the music: mood, genre, instruments, tempo, and intended use."
                    },
                    "duration_seconds": {
                        "type": "integer",
                        "description": "Length in seconds (default 30; clamped to 5–300)."
                    },
                    "output_path": {
                        "type": "string",
                        "description": "Where to write the audio, relative to the working directory (default assets/<slug>.mp3)."
                    }
                },
                "required": ["prompt"]
            },
            "mutating": true,
            "tier": STUDIO_TOOL_TIER
        }),
        json!({
            "name": "generate_jingle",
            "description": "Generate a short sonic-branding jingle (branded audio) for a brand or \
                product via Parslee Studio. Writes an audio file under the working directory and \
                returns its path — use it for brand stings, app/game intros, ad audio, logo sounds. \
                Takes ~30s. The result is a file path; embed it or read it like any file.",
            "parameters": {
                "type": "object",
                "properties": {
                    "brand_name": {
                        "type": "string",
                        "description": "The brand or product the jingle is for."
                    },
                    "style": {
                        "type": "string",
                        "description": "Optional musical style/mood (e.g. 'upbeat corporate', 'luxury cinematic')."
                    },
                    "tagline": {
                        "type": "string",
                        "description": "Optional tagline or lyric to feature."
                    },
                    "output_path": {
                        "type": "string",
                        "description": "Where to write the audio, relative to the working directory (default assets/<slug>-jingle.mp3)."
                    }
                },
                "required": ["brand_name"]
            },
            "mutating": true,
            "tier": STUDIO_TOOL_TIER
        }),
        json!({
            "name": "generate_studio_image",
            "description": "Generate a HIGH-QUALITY image via Parslee Studio (gpt-image-2). Unlike \
                the local generate_image, this reliably renders LEGIBLE TEXT inside the image \
                (titles, signage, logos with words) and follows complex prompts more faithfully — \
                use it for posters, covers, UI mockups with real labels, or any image that must \
                contain readable text. Writes the image under the working directory and returns \
                its path. It is SLOW (often 1–4 minutes) and near its time budget, so it can \
                occasionally return a timeout error — if that happens, retry once, or fall back \
                to the local generate_image (fast) when you don't need readable in-image text. \
                The result is a file path; reference it from HTML/CSS or read it like any file.",
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": "What the image should depict. Put any exact text that must appear in quotes."
                    },
                    "aspect_ratio": {
                        "type": "string",
                        "description": "e.g. '16:9', '1:1', '9:16' (default 16:9)."
                    },
                    "quality": {
                        "type": "string",
                        "description": "'low' | 'medium' | 'high' (default high)."
                    },
                    "output_path": {
                        "type": "string",
                        "description": "Where to write the PNG, relative to the working directory (default assets/<slug>.png)."
                    }
                },
                "required": ["prompt"]
            },
            "mutating": true,
            "tier": STUDIO_TOOL_TIER
        }),
        json!({
            "name": "generate_song",
            "description": "Generate a full SONG WITH VOCALS AND LYRICS via Parslee Studio (Suno), \
                up to 8 minutes — distinct from generate_music, which makes shorter instrumental / \
                ambience tracks. Use it when you want an actual song: a theme with a sung chorus, a \
                branded anthem, or lyrics you supply set to music. Writes an audio file under the \
                working directory and returns its path. Takes a few minutes (async). The result is \
                a file path; embed it or read it like any file.",
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": "Describe the song: theme, mood, genre, tempo."
                    },
                    "duration_seconds": {
                        "type": "integer",
                        "description": "Length in seconds (default 60; clamped to 30–480)."
                    },
                    "style": {
                        "type": "string",
                        "description": "Optional musical style (e.g. 'upbeat pop', 'orchestral cinematic')."
                    },
                    "lyrics": {
                        "type": "string",
                        "description": "Optional lyrics to sing. When provided, vocals are generated."
                    },
                    "instrumental": {
                        "type": "boolean",
                        "description": "True for no vocals (default: false when lyrics are given, else true)."
                    },
                    "title": {
                        "type": "string",
                        "description": "Optional song title."
                    },
                    "output_path": {
                        "type": "string",
                        "description": "Where to write the audio, relative to the working directory (default assets/<slug>.mp3)."
                    }
                },
                "required": ["prompt"]
            },
            "mutating": true,
            "tier": STUDIO_TOOL_TIER
        }),
    ]
}

impl StudioMediaTools {
    async fn run_generate_music(&self, params: &Value) -> Result<Value, String> {
        let prompt = params
            .get("prompt")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or("generate_music requires a non-empty `prompt`")?;
        let seconds = params
            .get("duration_seconds")
            .and_then(Value::as_u64)
            .unwrap_or(30)
            .clamp(5, 300);

        // Validate the destination BEFORE the (billed) network call.
        let (rel, final_path) =
            resolve_output_under(&self.root, params, &format!("assets/{}.mp3", slug(prompt)))?;

        let result = self
            .studio
            .generate_music(prompt, (seconds as u32) * 1000)
            .await
            .map_err(|e| format!("music generation failed: {e}"))?;

        // Fetch the time-limited URL and persist under root NOW (the SAS URL
        // expires) — path-artifact contract: return a path, never bytes.
        let bytes = self
            .http
            .get(&result.audio_url)
            .send()
            .await
            .map_err(|e| format!("download music: {e}"))?
            .error_for_status()
            .map_err(|e| format!("download music: {e}"))?
            .bytes()
            .await
            .map_err(|e| format!("read music bytes: {e}"))?;
        std::fs::write(&final_path, &bytes).map_err(|e| format!("write music file: {e}"))?;

        Ok(json!({
            "audio_path": rel,
            "media_type": "audio/mpeg",
            "bytes": bytes.len(),
            "note": format!("Wrote generated music to {rel} ({} KB). Embed it (<audio controls src=\"{rel}\">) or read it like any file.", bytes.len() / 1024),
        }))
    }

    async fn run_generate_jingle(&self, params: &Value) -> Result<Value, String> {
        let brand = params
            .get("brand_name")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or("generate_jingle requires a non-empty `brand_name`")?;
        let style = params
            .get("style")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty());
        let tagline = params
            .get("tagline")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty());

        // Validate the destination BEFORE the (billed) network call.
        let (rel, final_path) = resolve_output_under(
            &self.root,
            params,
            &format!("assets/{}-jingle.mp3", slug(brand)),
        )?;

        let result = self
            .studio
            .generate_jingle(brand, style, tagline, 1)
            .await
            .map_err(|e| format!("jingle generation failed: {e}"))?;
        let audio_url = result
            .audio_urls
            .first()
            .ok_or("Studio returned no jingle audio")?;

        // Fetch the time-limited URL and persist under root NOW (path-artifact
        // contract: return a path, never bytes).
        let bytes = self
            .http
            .get(audio_url)
            .send()
            .await
            .map_err(|e| format!("download jingle: {e}"))?
            .error_for_status()
            .map_err(|e| format!("download jingle: {e}"))?
            .bytes()
            .await
            .map_err(|e| format!("read jingle bytes: {e}"))?;
        std::fs::write(&final_path, &bytes).map_err(|e| format!("write jingle file: {e}"))?;

        Ok(json!({
            "audio_path": rel,
            "media_type": "audio/mpeg",
            "bytes": bytes.len(),
            "note": format!("Wrote generated jingle to {rel} ({} KB). Embed it (<audio controls src=\"{rel}\">) or read it like any file.", bytes.len() / 1024),
        }))
    }

    async fn run_generate_studio_image(&self, params: &Value) -> Result<Value, String> {
        let prompt = params
            .get("prompt")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or("generate_studio_image requires a non-empty `prompt`")?;
        let aspect = params
            .get("aspect_ratio")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty());
        let quality = params
            .get("quality")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty());

        // Validate the destination BEFORE the (billed) submit call.
        let (rel, final_path) =
            resolve_output_under(&self.root, params, &format!("assets/{}.png", slug(prompt)))?;

        // Single-pass gpt-image-2 (refinement disabled client-side) is ~1-2 min.
        // Studio allows gpt-image-2 up to 300s server-side
        // (AzureOpenAIImageClientOptions.TimeoutSeconds = 300), so give the poll
        // that full budget + margin — a shorter deadline gives up before the
        // server would, which earlier looked like a hang but was just latency.
        let deadline = Instant::now() + Duration::from_secs(330);
        let result = self
            .studio
            .generate_image_hq(prompt, aspect, quality, deadline)
            .await
            .map_err(|e| format!("studio image generation failed: {e}"))?;
        let n = self
            .download_to(&result.image_url, &final_path, "image")
            .await?;

        Ok(json!({
            "image_path": rel,
            "media_type": "image/png",
            "bytes": n,
            "note": format!("Wrote a high-quality Studio image to {rel} ({} KB). Reference it from HTML/CSS (<img src=\"{rel}\">) or read it like any file.", n / 1024),
        }))
    }

    async fn run_generate_song(&self, params: &Value) -> Result<Value, String> {
        let prompt = params
            .get("prompt")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or("generate_song requires a non-empty `prompt`")?;
        let seconds = params
            .get("duration_seconds")
            .and_then(Value::as_u64)
            .unwrap_or(60)
            .clamp(30, 480) as u32;
        let style = params
            .get("style")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty());
        let lyrics = params
            .get("lyrics")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty());
        let title = params
            .get("title")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty());
        // Default: vocals when lyrics are supplied, instrumental otherwise.
        let instrumental = params
            .get("instrumental")
            .and_then(Value::as_bool)
            .unwrap_or(lyrics.is_none());

        // Validate the destination BEFORE the (billed) submit call.
        let (rel, final_path) =
            resolve_output_under(&self.root, params, &format!("assets/{}.mp3", slug(prompt)))?;

        // Suno takes minutes; bound the internal poll at ~6 min.
        let deadline = Instant::now() + Duration::from_secs(360);
        let result = self
            .studio
            .generate_music_suno(
                prompt,
                seconds,
                style,
                instrumental,
                lyrics,
                title,
                deadline,
            )
            .await
            .map_err(|e| format!("song generation failed: {e}"))?;
        let n = self
            .download_to(&result.audio_url, &final_path, "song")
            .await?;

        Ok(json!({
            "audio_path": rel,
            "media_type": "audio/mpeg",
            "bytes": n,
            "note": format!("Wrote a generated song to {rel} ({} KB). Embed it (<audio controls src=\"{rel}\">) or read it like any file.", n / 1024),
        }))
    }

    /// Fetch a time-limited Studio artifact URL and persist the bytes under root
    /// NOW (the SAS URL expires) — the path-artifact contract. Returns byte count.
    async fn download_to(&self, url: &str, final_path: &Path, what: &str) -> Result<usize, String> {
        let bytes = self
            .http
            .get(url)
            .send()
            .await
            .map_err(|e| format!("download {what}: {e}"))?
            .error_for_status()
            .map_err(|e| format!("download {what}: {e}"))?
            .bytes()
            .await
            .map_err(|e| format!("read {what} bytes: {e}"))?;
        std::fs::write(final_path, &bytes).map_err(|e| format!("write {what} file: {e}"))?;
        Ok(bytes.len())
    }
}

#[async_trait]
impl ToolExecutor for StudioMediaTools {
    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
        match tool {
            "generate_music" => self.run_generate_music(params).await,
            "generate_jingle" => self.run_generate_jingle(params).await,
            "generate_studio_image" => self.run_generate_studio_image(params).await,
            "generate_song" => self.run_generate_song(params).await,
            // The prefix must be exactly "unknown tool" so ChainedDelegate falls
            // through to the next executor.
            other => Err(format!("unknown tool: '{other}'")),
        }
    }
}

/// Resolve a caller-supplied `output_path` (or default) to a validated absolute
/// path under the working root. Same contract as `MediaTools::resolve_output`:
/// lexical clamp, then — because this writer runs HOST-side and shares its mount
/// with an agent that has shell — canonicalize and re-assert the REAL parent is
/// under the REAL root (a planted symlink must not walk the writer out of root).
fn resolve_output_under(
    root: &Path,
    params: &Value,
    default_rel: &str,
) -> Result<(String, PathBuf), String> {
    let rel = params
        .get("output_path")
        .and_then(|v| v.as_str())
        .filter(|s| !s.trim().is_empty())
        .map(|s| s.to_string())
        .unwrap_or_else(|| default_rel.to_string());

    if !stays_under(root, &rel) {
        return Err(format!("output_path '{rel}' escapes the working directory"));
    }
    let abs = root.join(&rel);
    let parent = abs
        .parent()
        .ok_or_else(|| "output_path has no parent directory".to_string())?;
    std::fs::create_dir_all(parent).map_err(|e| format!("create output dir: {e}"))?;

    let root_real = root
        .canonicalize()
        .map_err(|e| format!("resolve working directory: {e}"))?;
    let parent_real = parent
        .canonicalize()
        .map_err(|e| format!("resolve output directory: {e}"))?;
    if !parent_real.starts_with(&root_real) {
        return Err(format!(
            "output_path '{rel}' resolves outside the working directory"
        ));
    }
    let file_name = abs
        .file_name()
        .ok_or_else(|| "output_path has no file name".to_string())?;
    Ok((rel, parent_real.join(file_name)))
}

/// A filesystem-safe, bounded slug for the default output name.
fn slug(s: &str) -> String {
    let mut out = String::new();
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c.to_ascii_lowercase());
        } else if !out.ends_with('-') {
            out.push('-');
        }
        if out.len() >= 40 {
            break;
        }
    }
    let trimmed = out.trim_matches('-').to_string();
    if trimmed.is_empty() {
        "music".to_string()
    } else {
        trimmed
    }
}

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

    #[tokio::test]
    async fn unknown_tool_falls_through() {
        let tools = StudioMediaTools::new(std::env::temp_dir());
        let err = tools.execute("nope", &json!({})).await.unwrap_err();
        assert!(err.starts_with("unknown tool"), "{err}");
    }

    #[test]
    fn studio_tools_require_full_access_tier() {
        let defs = studio_tool_defs();
        let names: Vec<_> = defs.iter().filter_map(|def| def["name"].as_str()).collect();
        assert_eq!(
            names,
            vec![
                "generate_music",
                "generate_jingle",
                "generate_studio_image",
                "generate_song"
            ]
        );
        for def in defs {
            assert_eq!(
                def["tier"], STUDIO_TOOL_TIER,
                "{} must require full-access approval because it calls an external Studio service",
                def["name"]
            );
            assert_eq!(def["mutating"], true);
        }
    }

    #[tokio::test]
    async fn generate_music_rejects_empty_prompt_and_escaping_path() {
        let tools = StudioMediaTools::new(std::env::temp_dir());
        // Empty prompt is rejected before any network call.
        assert!(tools
            .execute("generate_music", &json!({"prompt": "  "}))
            .await
            .unwrap_err()
            .contains("non-empty"));
        // Escaping output_path is rejected before any network call.
        assert!(tools
            .execute(
                "generate_music",
                &json!({"prompt": "x", "output_path": "../escape.mp3"})
            )
            .await
            .unwrap_err()
            .contains("escapes"));
    }

    #[tokio::test]
    async fn generate_jingle_rejects_empty_brand_and_escaping_path() {
        let tools = StudioMediaTools::new(std::env::temp_dir());
        assert!(tools
            .execute("generate_jingle", &json!({"brand_name": "  "}))
            .await
            .unwrap_err()
            .contains("non-empty"));
        assert!(tools
            .execute(
                "generate_jingle",
                &json!({"brand_name": "Apex", "output_path": "../escape.mp3"})
            )
            .await
            .unwrap_err()
            .contains("escapes"));
    }

    #[tokio::test]
    async fn generate_studio_image_rejects_empty_prompt_and_escaping_path() {
        let tools = StudioMediaTools::new(std::env::temp_dir());
        assert!(tools
            .execute("generate_studio_image", &json!({"prompt": "  "}))
            .await
            .unwrap_err()
            .contains("non-empty"));
        assert!(tools
            .execute(
                "generate_studio_image",
                &json!({"prompt": "x", "output_path": "../escape.png"})
            )
            .await
            .unwrap_err()
            .contains("escapes"));
    }

    #[tokio::test]
    async fn generate_song_rejects_empty_prompt_and_escaping_path() {
        let tools = StudioMediaTools::new(std::env::temp_dir());
        assert!(tools
            .execute("generate_song", &json!({"prompt": "  "}))
            .await
            .unwrap_err()
            .contains("non-empty"));
        assert!(tools
            .execute(
                "generate_song",
                &json!({"prompt": "x", "output_path": "../escape.mp3"})
            )
            .await
            .unwrap_err()
            .contains("escapes"));
    }

    #[test]
    fn slug_is_bounded_and_safe() {
        assert_eq!(slug("Upbeat Electronic!! Theme"), "upbeat-electronic-theme");
        assert_eq!(slug("***"), "music");
        assert!(slug(&"x".repeat(100)).len() <= 40);
    }
}
