//! Shortcuts.app bridge via the `shortcuts(1)` CLI.
//!
//! macOS's Shortcuts app is the OS-level automation bus: every app
//! that donates `AppShortcuts` ends up reachable from here, plus
//! every workflow the user has authored. From an agent's perspective
//! this is the cheapest path to "do a thing in another app" — much
//! cheaper than driving the UI via accessibility, much broader than
//! per-app AppleScript dictionaries.
//!
//! # API shape
//!
//! - [`list`] enumerates shortcuts the user can run.
//! - [`list_folders`] enumerates folders, for filtered listings.
//! - [`run`] invokes a shortcut by name or identifier, optionally
//!   passing input bytes and capturing the output bytes.
//!
//! All entry points subprocess to `/usr/bin/shortcuts`. We never
//! shell out — `tokio::process::Command` builds argv directly.
//!
//! # Permissions
//!
//! Running a shortcut inherits the permissions of every action
//! inside it. Some actions trigger TCC prompts on first use even
//! when invoked from the CLI. Surface this to users: "this shortcut
//! may prompt for permission the first time."

use std::process::Stdio;
use tokio::io::AsyncWriteExt;

use serde::{Deserialize, Serialize};

use crate::{AutomationError, AutomationOutput, Result};

const SHORTCUTS_BIN: &str = "/usr/bin/shortcuts";

/// One row from `shortcuts list`.
///
/// On macOS, this list contains both:
/// - **User shortcuts** — workflows the human authored in Shortcuts.app.
/// - **AppShortcuts** — actions that *apps* donated to the OS via the
///   App Intents framework. From the CLI these look identical, and the
///   CLI doesn't expose which is which.
///
/// For an agent registering tools, the distinction doesn't matter:
/// every entry here is something the agent can invoke. The shortcuts
/// CLI is therefore the practical "App Intents bridge" on macOS —
/// no separate framework binding needed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Shortcut {
    /// Display name as the user authored it.
    pub name: String,
    /// Stable UUID identifier, set when listing was requested with
    /// identifiers. Prefer this over `name` when invoking, since
    /// names can collide.
    pub identifier: Option<String>,
}

impl Shortcut {
    /// Sluggify the shortcut name for use as a tool/function name.
    ///
    /// Lowercases, replaces non-alphanumerics with underscores,
    /// collapses runs, prefixes with `shortcut_`. The original name
    /// can still collide across shortcuts, so consumers that need a
    /// unique key should fall back on the identifier.
    pub fn tool_slug(&self) -> String {
        let mut out = String::from("shortcut_");
        let mut prev_underscore = false;
        for ch in self.name.chars() {
            if ch.is_ascii_alphanumeric() {
                out.push(ch.to_ascii_lowercase());
                prev_underscore = false;
            } else if !prev_underscore && !out.ends_with('_') {
                out.push('_');
                prev_underscore = true;
            }
        }
        while out.ends_with('_') {
            out.pop();
        }
        out
    }

    /// JSON-Schema parameters object for registering this shortcut as
    /// an agent tool. Shortcuts take a single optional input — usually
    /// text — so the schema is uniform.
    ///
    /// Returns the parameter schema only (the runtime layer wraps this
    /// in its own ToolSchema). Keeps `car-automation` free of a
    /// dependency on `car-ir` / `car-engine`.
    pub fn parameters_schema(&self) -> serde_json::Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "input": {
                    "type": "string",
                    "description": "Optional input text passed to the shortcut as Shortcut Input."
                }
            }
        })
    }

    /// One-line description for tool registration. The CLI doesn't
    /// expose author-supplied descriptions; we generate one from the
    /// name plus identifier so the agent has *something* to ground a
    /// tool-selection decision in.
    pub fn tool_description(&self) -> String {
        match &self.identifier {
            Some(id) => format!("Run the macOS Shortcut \"{}\" (id={id}).", self.name),
            None => format!("Run the macOS Shortcut \"{}\".", self.name),
        }
    }
}

/// Enumerate available shortcuts.
///
/// `folder` filters to a specific folder; pass `Some("none")` to
/// list shortcuts not in any folder. `with_identifiers` requests
/// stable UUIDs alongside names.
pub async fn list(folder: Option<&str>, with_identifiers: bool) -> Result<Vec<Shortcut>> {
    #[cfg(not(target_os = "macos"))]
    {
        let _ = (folder, with_identifiers);
        return Err(AutomationError::PlatformUnsupported {
            reason: "shortcuts CLI is only available on macOS",
        });
    }

    #[cfg(target_os = "macos")]
    {
        let mut cmd = tokio::process::Command::new(SHORTCUTS_BIN);
        cmd.arg("list");
        if let Some(f) = folder {
            cmd.arg("--folder-name").arg(f);
        }
        if with_identifiers {
            cmd.arg("--show-identifiers");
        }
        let output = run_capture(cmd).await?;
        Ok(parse_listing(&output.stdout, with_identifiers))
    }
}

/// Enumerate folders the user has organized shortcuts into.
pub async fn list_folders() -> Result<Vec<String>> {
    #[cfg(not(target_os = "macos"))]
    {
        return Err(AutomationError::PlatformUnsupported {
            reason: "shortcuts CLI is only available on macOS",
        });
    }

    #[cfg(target_os = "macos")]
    {
        let mut cmd = tokio::process::Command::new(SHORTCUTS_BIN);
        cmd.arg("list").arg("--folders");
        let output = run_capture(cmd).await?;
        Ok(output
            .stdout
            .lines()
            .map(|line| line.trim().to_string())
            .filter(|line| !line.is_empty())
            .collect())
    }
}

/// Invoke a shortcut.
///
/// `name_or_id` is either the user-visible name or a UUID from
/// [`Shortcut::identifier`]. Prefer the UUID — names can collide
/// and change.
///
/// `input` is piped to the shortcut's "Shortcut Input" via the CLI's
/// `--input-path` flag (we route through a temp file because the CLI
/// doesn't accept input on stdin). `None` runs without input.
///
/// `output_type` is a Uniform Type Identifier (e.g. `"public.plain-text"`,
/// `"public.json"`). `None` lets the shortcut decide.
///
/// Returns the captured stdout, stderr, and exit code.
pub async fn run(
    name_or_id: &str,
    input: Option<&[u8]>,
    output_type: Option<&str>,
    timeout: Option<std::time::Duration>,
) -> Result<AutomationOutput> {
    #[cfg(not(target_os = "macos"))]
    {
        let _ = (name_or_id, input, output_type, timeout);
        return Err(AutomationError::PlatformUnsupported {
            reason: "shortcuts CLI is only available on macOS",
        });
    }

    #[cfg(target_os = "macos")]
    {
        if name_or_id.is_empty() {
            return Err(AutomationError::InvalidInput(
                "shortcut name/identifier is empty".into(),
            ));
        }

        // Stage input bytes through a tempfile if provided. The CLI
        // streams output to stdout when `--output-path -` is set.
        let input_file = if let Some(bytes) = input {
            let path = std::env::temp_dir().join(format!(
                "car-shortcut-input-{}",
                std::process::id().wrapping_add(rand_suffix()),
            ));
            tokio::fs::write(&path, bytes).await?;
            Some(path)
        } else {
            None
        };

        let mut cmd = tokio::process::Command::new(SHORTCUTS_BIN);
        cmd.arg("run").arg(name_or_id);
        if let Some(p) = input_file.as_ref() {
            cmd.arg("--input-path").arg(p);
        }
        cmd.arg("--output-path").arg("-");
        if let Some(t) = output_type {
            cmd.arg("--output-type").arg(t);
        }

        let result = match timeout {
            Some(dur) => match tokio::time::timeout(dur, run_capture(cmd)).await {
                Ok(r) => r,
                Err(_) => Err(AutomationError::SubprocessFailed {
                    exit_code: None,
                    stdout: String::new(),
                    stderr: format!("shortcuts run timed out after {dur:?}"),
                }),
            },
            None => run_capture(cmd).await,
        };

        if let Some(p) = input_file {
            let _ = tokio::fs::remove_file(&p).await;
        }
        result
    }
}

#[cfg(target_os = "macos")]
async fn run_capture(mut cmd: tokio::process::Command) -> Result<AutomationOutput> {
    cmd.stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    let mut child = cmd
        .spawn()
        .map_err(|e| AutomationError::Spawn(format!("shortcuts: {e}")))?;
    if let Some(mut stdin) = child.stdin.take() {
        // Defensive — stdin is set to null above, but if a future
        // refactor pipes it, drain to avoid deadlock.
        stdin.shutdown().await.ok();
    }

    let output = child.wait_with_output().await?;
    let stdout = String::from_utf8_lossy(&output.stdout)
        .trim_end_matches('\n')
        .to_string();
    let stderr = String::from_utf8_lossy(&output.stderr)
        .trim_end_matches('\n')
        .to_string();
    let exit_code = output.status.code();

    if !output.status.success() {
        return Err(AutomationError::SubprocessFailed {
            exit_code,
            stdout,
            stderr,
        });
    }
    Ok(AutomationOutput {
        stdout,
        stderr,
        exit_code,
    })
}

#[cfg(target_os = "macos")]
fn rand_suffix() -> u32 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.subsec_nanos())
        .unwrap_or(0)
}

/// Parse `shortcuts list` output. Each line is either:
///   `Name`
///   `Name (UUID)`        when --show-identifiers is set
fn parse_listing(stdout: &str, with_identifiers: bool) -> Vec<Shortcut> {
    stdout
        .lines()
        .filter_map(|line| {
            let trimmed = line.trim();
            if trimmed.is_empty() {
                return None;
            }
            if !with_identifiers {
                return Some(Shortcut {
                    name: trimmed.to_string(),
                    identifier: None,
                });
            }
            // Strip a trailing ` (UUID)` if present. UUIDs are the
            // last whitespace-separated token wrapped in parens.
            if let Some(open) = trimmed.rfind(" (") {
                if trimmed.ends_with(')') {
                    let name = trimmed[..open].to_string();
                    let id = trimmed[open + 2..trimmed.len() - 1].to_string();
                    return Some(Shortcut {
                        name,
                        identifier: Some(id),
                    });
                }
            }
            Some(Shortcut {
                name: trimmed.to_string(),
                identifier: None,
            })
        })
        .collect()
}

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

    #[test]
    fn parses_listing_without_identifiers() {
        let stdout = "First Shortcut\nSecond Shortcut\n\n";
        let parsed = parse_listing(stdout, false);
        assert_eq!(parsed.len(), 2);
        assert_eq!(parsed[0].name, "First Shortcut");
        assert!(parsed[0].identifier.is_none());
    }

    #[test]
    fn parses_listing_with_identifiers() {
        let stdout = "Shazam shortcut (43E9830F-17C8-4B1C-BA46-084D6B221A03)\n\
                      Block Off an Hour (BE005778-CF4B-414C-9E6B-BDF650BBC7DE)\n";
        let parsed = parse_listing(stdout, true);
        assert_eq!(parsed.len(), 2);
        assert_eq!(parsed[0].name, "Shazam shortcut");
        assert_eq!(
            parsed[0].identifier.as_deref(),
            Some("43E9830F-17C8-4B1C-BA46-084D6B221A03")
        );
        assert_eq!(parsed[1].name, "Block Off an Hour");
    }

    #[test]
    fn tool_slug_handles_punctuation_and_unicode() {
        let s = Shortcut {
            name: "What's a shortcut?".into(),
            identifier: None,
        };
        assert_eq!(s.tool_slug(), "shortcut_what_s_a_shortcut");

        let s = Shortcut {
            name: "Block Off an Hour!!".into(),
            identifier: None,
        };
        assert_eq!(s.tool_slug(), "shortcut_block_off_an_hour");

        let s = Shortcut {
            name: "  multi   space  ".into(),
            identifier: None,
        };
        assert_eq!(s.tool_slug(), "shortcut_multi_space");
    }

    #[test]
    fn parameters_schema_has_input_field() {
        let s = Shortcut {
            name: "Anything".into(),
            identifier: None,
        };
        let schema = s.parameters_schema();
        assert_eq!(schema["type"], "object");
        assert!(schema["properties"]["input"].is_object());
    }

    #[test]
    fn tool_description_includes_identifier() {
        let s = Shortcut {
            name: "Open Notes".into(),
            identifier: Some("ABC".into()),
        };
        assert!(s.tool_description().contains("Open Notes"));
        assert!(s.tool_description().contains("ABC"));
    }

    #[test]
    fn parses_name_with_parentheses_correctly() {
        // The name itself contains parens — only the trailing UUID
        // should be stripped.
        let stdout = "My (Cool) Shortcut (DEAD-BEEF)\n";
        let parsed = parse_listing(stdout, true);
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].name, "My (Cool) Shortcut");
        assert_eq!(parsed[0].identifier.as_deref(), Some("DEAD-BEEF"));
    }

    #[cfg(target_os = "macos")]
    #[tokio::test]
    async fn lists_shortcuts() {
        // Doesn't assert on contents — the user's shortcut library
        // is unknown — just that the call returns Ok and parses.
        let result = list(None, true).await.expect("list should succeed");
        for s in &result {
            assert!(!s.name.is_empty());
        }
    }

    #[cfg(target_os = "macos")]
    #[tokio::test]
    async fn run_rejects_empty_name() {
        let err = run("", None, None, None).await.unwrap_err();
        assert!(matches!(err, AutomationError::InvalidInput(_)));
    }

    #[cfg(not(target_os = "macos"))]
    #[tokio::test]
    async fn returns_unsupported_off_macos() {
        assert!(matches!(
            list(None, false).await.unwrap_err(),
            AutomationError::PlatformUnsupported { .. }
        ));
        assert!(matches!(
            run("any", None, None, None).await.unwrap_err(),
            AutomationError::PlatformUnsupported { .. }
        ));
    }
}
