use core::fmt;
use std::collections::HashMap;
use std::fmt::Display;
use std::path::Path;
use std::{fs::read_to_string, sync::Arc};

use apollo_compiler::{Schema, validation::Valid};
use rmcp::model::Tool;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use tracing::debug;
use url::Url;

use crate::apps::app::{
    App, AppResource, AppResourceSource, AppTool, PrefetchOperation, TargetedAppResource,
};
use crate::{
    custom_scalar_map::CustomScalarMap,
    operations::{MutationMode, Operation, RawOperation},
};

const MANIFEST_FILE_NAME: &str = ".application-manifest.json";

pub(crate) fn load_from_path(
    path: &Path,
    schema: &Valid<Schema>,
    custom_scalar_map: Option<&CustomScalarMap>,
    mutation_mode: MutationMode,
    disable_type_description: bool,
    disable_schema_description: bool,
    enable_output_schema: bool,
) -> Result<Vec<App>, String> {
    let Ok(apps_dir) = path.read_dir() else {
        return Ok(Vec::new());
    };

    let mut apps = Vec::new();
    for app_dir in apps_dir {
        let app_dir = match app_dir {
            Ok(app_dir) => app_dir,
            Err(err) => {
                debug!("Failed to read app directory, ignoring: {}", err);
                continue;
            }
        };
        let path = app_dir.path();
        if !path.is_dir() {
            debug!("{} is not a directory, ignoring", path.to_string_lossy());
            continue;
        }

        let Ok(manifest) = read_to_string(path.join(MANIFEST_FILE_NAME)) else {
            debug!(
                "No manifest file found in {}, ignoring",
                path.to_string_lossy()
            );
            continue;
        };

        let manifest: Manifest = serde_json::from_str(&manifest).map_err(|err| {
            format!(
                "Failed to parse manifest from {}: {}",
                path.to_string_lossy(),
                err
            )
        })?;

        let name = manifest
            .name
            .unwrap_or_else(|| app_dir.file_name().to_string_lossy().to_string());
        let description = manifest.description;

        let uri_string = format!("ui://widget/{name}#{}", manifest.hash);
        let uri = Url::parse(&uri_string)
            .map_err(|err| format!("Failed to create a URI for resource {uri_string}: {err}",))?;

        let mut prefetch_operations = Vec::new();
        let mut tools = Vec::new();

        for operation_def in manifest.operations {
            let raw = RawOperation::from((operation_def.body, path.to_str().map(String::from)));
            let operation = match Operation::from_raw(
                raw,
                schema,
                custom_scalar_map,
                mutation_mode,
                disable_type_description,
                disable_schema_description,
                enable_output_schema,
                &HashMap::new(),
                &HashMap::new(),
            ) {
                Err(err) => {
                    return Err(format!(
                        "Failed to parse operation from {path}: {err}",
                        path = path.to_string_lossy()
                    ));
                }
                Ok(None) => {
                    return Err(format!(
                        "Failed parsing tools: No operation in {path}",
                        path = path.to_string_lossy()
                    ));
                }
                Ok(Some(op)) => Arc::new(op),
            };

            for tool in operation_def.tools {
                // Default to manifest-level labels, override with tool-specific labels if present
                let mut labels = manifest.labels.clone().unwrap_or_default();
                if let Some(tool_labels) = tool.labels {
                    labels.tool_invocation_invoking = tool_labels
                        .tool_invocation_invoking
                        .or(labels.tool_invocation_invoking);
                    labels.tool_invocation_invoked = tool_labels
                        .tool_invocation_invoked
                        .or(labels.tool_invocation_invoked);
                }

                let mut mcp_tool = Tool::new_with_raw(
                    tool.name,
                    Some(if let Some(app_description) = description.clone() {
                        format!("{} {}", app_description, tool.description).into()
                    } else {
                        tool.description.into()
                    }),
                    if let Some(extra_inputs) = tool.extra_inputs {
                        let mut merged = operation.tool.input_schema.as_ref().clone();
                        merge_inputs(&mut merged, extra_inputs)?;
                        Arc::new(merged)
                    } else {
                        operation.tool.input_schema.clone()
                    },
                );
                mcp_tool.title = operation.tool.title.clone();
                mcp_tool.output_schema = operation.tool.output_schema.clone();
                mcp_tool.annotations = operation.tool.annotations.clone();
                mcp_tool.icons = operation.tool.icons.clone();

                tools.push(AppTool {
                    operation: operation.clone(),
                    labels,
                    tool: mcp_tool,
                    extra_outputs: tool.extra_outputs,
                })
            }

            if let Some(prefetch_id) = operation_def.prefetch_id {
                prefetch_operations.push(PrefetchOperation {
                    prefetch_id,
                    operation,
                });
            }
        }

        // We may have a resource with just a single string OR an object specifying mcp + openai resources. Any of these could be file path or url.
        let resource = match manifest.resource {
            ManifestResource::Targeted(targets) => AppResource::Targeted(TargetedAppResource {
                mcp: targets
                    .mcp
                    .map(|resource| resource_source_from_string(resource, &path))
                    .transpose()?,
                openai: targets
                    .openai
                    .map(|resource| resource_source_from_string(resource, &path))
                    .transpose()?,
            }),
            ManifestResource::Single(resource) => {
                AppResource::Single(resource_source_from_string(resource, &path)?)
            }
        };

        apps.push(App {
            name,
            description,
            uri,
            resource,
            csp_settings: manifest.csp,
            widget_settings: manifest.widget_settings,
            tools,
            prefetch_operations,
        });
    }
    Ok(apps)
}

fn resource_source_from_string(resource: String, path: &Path) -> Result<AppResourceSource, String> {
    if resource.starts_with("http://") || resource.starts_with("https://") {
        let url = Url::parse(&resource)
            .map_err(|err| format!("Failed to parse resource URL {}: {err}", resource))?;
        Ok(AppResourceSource::Remote(url))
    } else {
        let resource_path = path.join(&resource);
        let contents = read_to_string(&resource_path).map_err(|err| {
            format!(
                "Failed to read resource from {resource_path}: {err}",
                resource_path = resource_path.to_string_lossy(),
            )
        })?;
        Ok(AppResourceSource::Local(contents))
    }
}

fn merge_inputs(
    orig: &mut Map<String, Value>,
    extra: Vec<ExtraInputDefinition>,
) -> Result<(), String> {
    let mut properties = match orig.remove("properties") {
        Some(Value::Object(props)) => props,
        _ => Map::default(),
    };

    let mut required = match orig.remove("required") {
        Some(Value::Array(req)) => req,
        _ => Vec::default(),
    };

    for extra_input in extra {
        if properties.contains_key(&extra_input.name) {
            return Err(format!(
                "Extra input with name '{}' failed to process because another input with this name was already processed. Make sure your extra_input names are unique, both from each other and any graphql variables you may have.",
                extra_input.name
            ));
        }

        if extra_input.required {
            let value = Value::String(extra_input.name.clone());
            if !required.contains(&value) {
                required.push(value);
            }
        }

        properties.insert(
            extra_input.name,
            json!({
                "description": extra_input.description,
                "type": extra_input.value_type.to_string()
            }),
        );
    }

    orig.insert("properties".to_string(), Value::Object(properties));
    orig.insert("required".to_string(), Value::Array(required));

    Ok(())
}

#[derive(Clone, Deserialize)]
struct Manifest {
    hash: String,
    operations: Vec<OperationDefinition>,
    resource: ManifestResource,
    name: Option<String>,
    description: Option<String>,
    csp: Option<CSPSettings>,
    labels: Option<AppLabels>,
    #[serde(rename = "widgetSettings")]
    widget_settings: Option<WidgetSettings>,
    #[allow(dead_code)] // Only used to verify we recognize the file
    format: ManifestFormat,
    #[allow(dead_code)] // Only used to verify we recognize the version
    version: ManifestVersion,
}

#[derive(Clone, Deserialize)]
#[serde(untagged)]
enum ManifestResource {
    Targeted(TargetedManifestResource),
    Single(String),
}

#[derive(Clone, Deserialize)]
pub(crate) struct TargetedManifestResource {
    openai: Option<String>,
    mcp: Option<String>,
}

#[derive(Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum ManifestFormat {
    ApolloAiAppManifest,
}

#[derive(Clone, Copy, Deserialize)]
enum ManifestVersion {
    #[serde(rename = "1")]
    V1,
}

#[derive(Clone, Debug, Deserialize, Default)]
pub(crate) struct AppLabels {
    #[serde(rename = "toolInvocation/invoking")]
    pub(crate) tool_invocation_invoking: Option<String>,
    #[serde(rename = "toolInvocation/invoked")]
    pub(crate) tool_invocation_invoked: Option<String>,
}

#[derive(Clone, Deserialize, Debug)]
pub(crate) struct WidgetSettings {
    pub(crate) description: Option<String>,
    pub(crate) domain: Option<String>,
    #[serde(rename = "prefersBorder")]
    pub(crate) prefers_border: Option<bool>,
}

#[derive(Clone, Deserialize)]
struct OperationDefinition {
    /// The GraphQL operation itself
    body: String,
    /// If this operation should be prefetched, this ID indicates where the UI expects to find the data
    #[serde(rename = "prefetchID", default)]
    prefetch_id: Option<String>,
    /// The tools which make up this app
    tools: Vec<ToolDefinition>,
}

#[derive(Clone, Deserialize)]
struct ToolDefinition {
    name: String,
    description: String,
    #[serde(rename = "extraInputs", default)]
    extra_inputs: Option<Vec<ExtraInputDefinition>>,
    #[serde(rename = "extraOutputs", default)]
    extra_outputs: Option<Value>,
    labels: Option<AppLabels>,
}

#[derive(Clone, Deserialize)]
#[serde(rename_all = "lowercase")]
enum JsonSchemaType {
    String,
    Number,
    Boolean,
    Integer,
    Array,
    Object,
}

impl Display for JsonSchemaType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            JsonSchemaType::String => "string",
            JsonSchemaType::Number => "number",
            JsonSchemaType::Boolean => "boolean",
            JsonSchemaType::Integer => "integer",
            JsonSchemaType::Array => "array",
            JsonSchemaType::Object => "object",
        })
    }
}

#[derive(Clone, Deserialize)]
struct ExtraInputDefinition {
    name: String,
    description: String,
    #[serde(rename = "type")]
    value_type: JsonSchemaType,
    #[serde(default)]
    required: bool,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all(deserialize = "camelCase"))]
pub(crate) struct CSPSettings {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) connect_domains: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) resource_domains: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) frame_domains: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) redirect_domains: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) base_uri_domains: Option<Vec<String>>,
}

#[cfg(test)]
mod test_load_from_path {
    use super::*;
    use crate::apps::app::AppResourceSource;
    use assert_fs::{TempDir, prelude::*};

    #[test]
    fn local_resource() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("MyApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                            "version": "1",
                            "hash": "abcdef",
                            "resource": "index.html",
                            "operations": []}"#,
            )
            .unwrap();
        let html = "<html>blelo</html>";
        app_dir.child("index.html").write_str(html).unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        match &app.resource {
            AppResource::Single(AppResourceSource::Local(contents)) => {
                assert_eq!(contents, html)
            }
            other => panic!("unexpected resource {other:?}"),
        }
        assert_eq!(app.uri, "ui://widget/MyApp#abcdef".parse().unwrap());
    }

    #[test]
    fn remote_resource() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("RemoteApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                            "version": "1",
                            "hash": "abcdef",
                            "resource": "https://example.com/widget/index.html",
                            "operations": []}"#,
            )
            .unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        match &app.resource {
            AppResource::Single(AppResourceSource::Remote(url)) => {
                assert_eq!(url.as_str(), "https://example.com/widget/index.html")
            }
            other => {
                panic!("expected remote resource, found: {other:?}")
            }
        }
    }

    #[test]
    fn multiple_tools_in_an_app() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("MultipleToolsApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                    "version": "1",
                    "hash": "abcdef",
                    "resource": "https://example.com/widget/index.html",
                    "operations": [
                        {"body": "query MyOperation { hello }", "tools": [
                          {"name": "Tool1", "description": "Description for Tool1"},
                          {"name": "Tool2", "description": "Description for Tool2"}
                        ]},
                        {"body": "query MyOtherOperation { world }", "tools": [
                          {"name": "Tool3", "description": "Description for Tool3"}
                        ]}
                    ]}"#,
            )
            .unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse(
                "type Query { hello: String, world: String }",
                "schema.graphql",
            )
            .unwrap()
            .validate()
            .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        assert_eq!(app.tools.len(), 3);
        assert_eq!(app.tools[0].tool.name, "Tool1");
        assert_eq!(
            app.tools[0].tool.description.as_ref().unwrap(),
            "Description for Tool1"
        );
        assert_eq!(
            app.tools[0].operation.inner.source_text,
            "query MyOperation { hello }"
        );
        assert_eq!(app.tools[1].tool.name, "Tool2");
        assert_eq!(
            app.tools[1].tool.description.as_ref().unwrap(),
            "Description for Tool2"
        );
        assert_eq!(app.tools[2].tool.name, "Tool3");
        assert_eq!(
            app.tools[2].tool.description.as_ref().unwrap(),
            "Description for Tool3"
        );
    }

    #[test]
    fn prefetch_operations() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("MultipleToolsApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                    "version": "1",
                    "hash": "abcdef",
                    "resource": "https://example.com/widget/index.html",
                    "operations": [
                        {"body": "query MyOperation { hello }", "tools": [
                          {"name": "Tool1", "description": "Description for Tool1"}
                        ], "prefetchID": "prefetchId1"},
                        {"body": "query MyOtherOperation { world }", "tools": [], "prefetchID": "prefetchId2"}
                    ]}"#,
            )
            .unwrap();

        let apps = load_from_path(
            temp.path(),
            &Schema::parse(
                "type Query { hello: String, world: String }",
                "schema.graphql",
            )
            .unwrap()
            .validate()
            .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        assert_eq!(app.tools.len(), 1);
        assert_eq!(app.prefetch_operations.len(), 2);
        let prefetch_that_matches_tool = app
            .prefetch_operations
            .iter()
            .find(|prefetch| prefetch.prefetch_id == "prefetchId1")
            .unwrap();
        assert!(
            Arc::ptr_eq(
                &prefetch_that_matches_tool.operation,
                &app.tools[0].operation
            ),
            "Prefetches should be deduplicated via Arc comparison"
        )
    }

    #[test]
    fn should_map_extra_inputs_to_input_schema() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("ExtraInputsApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                    "version": "1",
                    "hash": "abcdef",
                    "resource": "https://example.com/widget/index.html",
                    "operations": [
                        {"body": "query MyOperation { hello }", "tools": [
                          {"name": "Tool1", "description": "Description for Tool1", "extraInputs": [{
                            "name": "isAwesome",
                            "type": "boolean",
                            "description": "Is everything awesome?",
                            "required": true
                          }]}
                        ]}
                    ]}"#,
            )
            .unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse(
                "type Query { hello: String, world: String }",
                "schema.graphql",
            )
            .unwrap()
            .validate()
            .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");

        let input_schema = &apps[0].tools[0].tool.input_schema;

        let properties = input_schema
            .get("properties")
            .expect("Should have a properties property")
            .as_object()
            .expect("Properties should be a map");
        let required = input_schema
            .get("required")
            .expect("Should have a required property")
            .as_array()
            .expect("Required should be an array");

        assert!(properties.contains_key("isAwesome"));
        assert!(required.contains(&Value::String("isAwesome".to_string())));
    }

    #[test]
    fn should_error_when_multiple_extra_inputs_with_same_name() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("ExtraInputsSameNameApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                    "version": "1",
                    "hash": "abcdef",
                    "resource": "https://example.com/widget/index.html",
                    "operations": [
                        {"body": "query MyOperation { hello }", "tools": [
                          {"name": "Tool1", "description": "Description for Tool1", "extraInputs": [{
                            "name": "isAwesome",
                            "type": "boolean",
                            "description": "Is everything awesome?",
                            "required": true
                          },
                          {
                            "name": "isAwesome",
                            "type": "boolean",
                            "description": "Is everything awesome still?",
                            "required": false
                          }
                          ]}
                        ]}
                    ]}"#,
            )
            .unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse(
                "type Query { hello: String, world: String }",
                "schema.graphql",
            )
            .unwrap()
            .validate()
            .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        );

        assert!(apps.is_err());
        assert_eq!(
            apps.err().unwrap(),
            "Extra input with name 'isAwesome' failed to process because another input with this name was already processed. Make sure your extra_input names are unique, both from each other and any graphql variables you may have."
        )
    }

    #[test]
    fn should_error_when_extra_input_name_conflicts_with_graphql_variable() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("ExtraInputsSameNameApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                    "version": "1",
                    "hash": "abcdef",
                    "resource": "https://example.com/widget/index.html",
                    "operations": [
                        {"body": "query MyOperation($isAwesome: Boolean) { hello(isAwesome: $isAwesome) }", "tools": [
                          {"name": "Tool1", "description": "Description for Tool1", "extraInputs": [{
                            "name": "isAwesome",
                            "type": "boolean",
                            "description": "Is everything awesome?",
                            "required": true
                          }
                          ]}
                        ]}
                    ]}"#,
            )
            .unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse(
                "type Query { hello(isAwesome: Boolean): String, world: String }",
                "schema.graphql",
            )
            .unwrap()
            .validate()
            .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        );

        assert!(apps.is_err());
        assert_eq!(
            apps.err().unwrap(),
            "Extra input with name 'isAwesome' failed to process because another input with this name was already processed. Make sure your extra_input names are unique, both from each other and any graphql variables you may have."
        )
    }

    #[test]
    fn should_not_have_tool_invocation_labels_when_not_specified() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("MyApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                            "version": "1",
                            "hash": "abcdef",
                            "resource": "index.html",
                            "operations": [
                                {
                                    "body": "query MyOperation { hello }", 
                                    "tools": [
                                        {"name": "Tool1", "description": "Description for Tool1" }
                                    ]
                                }
                            ]}"#,
            )
            .unwrap();
        let html = "<html>blelo</html>";
        app_dir.child("index.html").write_str(html).unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        let tool = &app.tools[0];

        assert!(tool.labels.tool_invocation_invoking.is_none());
        assert!(tool.labels.tool_invocation_invoked.is_none());
    }

    #[test]
    fn should_have_tool_invocation_labels_when_specified_in_manifest() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("MyApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                            "version": "1",
                            "hash": "abcdef",
                            "resource": "index.html",
                            "operations": [
                                {
                                    "body": "query MyOperation { hello }", 
                                    "tools": [
                                        {"name": "Tool1", "description": "Description for Tool1" },
                                        {"name": "Tool2", "description": "Description for Tool2" }
                                    ]
                                }
                            ],
                            "labels": {
                                "toolInvocation/invoking": "Store is invoking...",
                                "toolInvocation/invoked": "Happy shopping!"
                            }
                        }"#,
            )
            .unwrap();
        let html = "<html>blelo</html>";
        app_dir.child("index.html").write_str(html).unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        let tool1 = &app.tools[0];
        let tool2 = &app.tools[1];

        assert_eq!(
            tool1.labels.tool_invocation_invoking,
            Some("Store is invoking...".to_string())
        );
        assert_eq!(
            tool1.labels.tool_invocation_invoked,
            Some("Happy shopping!".to_string())
        );
        assert_eq!(
            tool2.labels.tool_invocation_invoking,
            Some("Store is invoking...".to_string())
        );
        assert_eq!(
            tool2.labels.tool_invocation_invoked,
            Some("Happy shopping!".to_string())
        );
    }

    #[test]
    fn should_have_tool_invocation_labels_overridden_when_specified_by_tool() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("MyApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                            "version": "1",
                            "hash": "abcdef",
                            "resource": "index.html",
                            "operations": [
                                {
                                    "body": "query MyOperation { hello }", 
                                    "tools": [
                                        {"name": "Tool1", "description": "Description for Tool1" },
                                        {"name": "Tool2", "description": "Description for Tool2", "labels": {
                                                "toolInvocation/invoking": "Adding to cart...",
                                                "toolInvocation/invoked": "Cart filled!"
                                            }
                                        }
                                    ]
                                }
                            ],
                            "labels": {
                                "toolInvocation/invoking": "Store is invoking...",
                                "toolInvocation/invoked": "Happy shopping!"
                            }
                        }"#,
            )
            .unwrap();
        let html = "<html>blelo</html>";
        app_dir.child("index.html").write_str(html).unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        let tool1 = &app.tools[0];
        let tool2 = &app.tools[1];

        assert_eq!(
            tool1.labels.tool_invocation_invoking,
            Some("Store is invoking...".to_string())
        );
        assert_eq!(
            tool1.labels.tool_invocation_invoked,
            Some("Happy shopping!".to_string())
        );
        assert_eq!(
            tool2.labels.tool_invocation_invoking,
            Some("Adding to cart...".to_string())
        );
        assert_eq!(
            tool2.labels.tool_invocation_invoked,
            Some("Cart filled!".to_string())
        );
    }

    #[test]
    fn should_have_tool_invocation_labels_when_only_specified_by_tool() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("MyApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                            "version": "1",
                            "hash": "abcdef",
                            "resource": "index.html",
                            "operations": [
                                {
                                    "body": "query MyOperation { hello }", 
                                    "tools": [
                                        {"name": "Tool1", "description": "Description for Tool1" },
                                        {"name": "Tool2", "description": "Description for Tool2", "labels": {
                                                "toolInvocation/invoking": "Adding to cart...",
                                                "toolInvocation/invoked": "Cart filled!"
                                            }
                                        }
                                    ]
                                }
                            ]
                        }"#,
            )
            .unwrap();
        let html = "<html>blelo</html>";
        app_dir.child("index.html").write_str(html).unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        let tool1 = &app.tools[0];
        let tool2 = &app.tools[1];

        assert!(tool1.labels.tool_invocation_invoking.is_none());
        assert!(tool1.labels.tool_invocation_invoked.is_none());
        assert_eq!(
            tool2.labels.tool_invocation_invoking,
            Some("Adding to cart...".to_string())
        );
        assert_eq!(
            tool2.labels.tool_invocation_invoked,
            Some("Cart filled!".to_string())
        );
    }

    #[test]
    fn should_load_local_files_when_resource_is_targeted() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("TargetedApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                            "version": "1",
                            "hash": "abcdef",
                            "resource": {
                                "openai": "openai.html",
                                "mcp": "mcp.html"
                            },
                            "operations": []}"#,
            )
            .unwrap();
        let openai_html = "<html>openai</html>";
        let mcp_html = "<html>mcp</html>";
        app_dir.child("openai.html").write_str(openai_html).unwrap();
        app_dir.child("mcp.html").write_str(mcp_html).unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        match &app.resource {
            AppResource::Targeted(targeted) => {
                match targeted
                    .openai
                    .as_ref()
                    .expect("openai resource should exist")
                {
                    AppResourceSource::Local(contents) => assert_eq!(contents, openai_html),
                    other => panic!("expected local openai resource, found: {other:?}"),
                }
                match targeted.mcp.as_ref().expect("mcp resource should exist") {
                    AppResourceSource::Local(contents) => assert_eq!(contents, mcp_html),
                    other => panic!("expected local mcp resource, found: {other:?}"),
                }
            }
            other => panic!("expected targeted resource, found: {other:?}"),
        }
    }

    #[test]
    fn should_load_remote_urls_when_resource_is_targeted() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("TargetedRemoteApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                            "version": "1",
                            "hash": "abcdef",
                            "resource": {
                                "openai": "https://example.com/openai.html",
                                "mcp": "https://example.com/mcp.html"
                            },
                            "operations": []}"#,
            )
            .unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");
        assert_eq!(apps.len(), 1);
        let app = &apps[0];
        match &app.resource {
            AppResource::Targeted(targeted) => {
                match targeted
                    .openai
                    .as_ref()
                    .expect("openai resource should exist")
                {
                    AppResourceSource::Remote(url) => {
                        assert_eq!(url.as_str(), "https://example.com/openai.html")
                    }
                    other => panic!("expected remote openai resource, found: {other:?}"),
                }
                match targeted.mcp.as_ref().expect("mcp resource should exist") {
                    AppResourceSource::Remote(url) => {
                        assert_eq!(url.as_str(), "https://example.com/mcp.html")
                    }
                    other => panic!("expected remote mcp resource, found: {other:?}"),
                }
            }
            other => panic!("expected targeted resource, found: {other:?}"),
        }
    }

    #[test]
    fn should_store_extra_outputs_on_app_tool() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("ExtraOutputsApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                    "version": "1",
                    "hash": "abcdef",
                    "resource": "https://example.com/widget/index.html",
                    "operations": [
                        {"body": "query MyOperation { hello }", "tools": [
                          {"name": "Tool1", "description": "Description for Tool1", "extraOutputs": {"widgetUrl": "https://example.com", "version": 2}}
                        ]}
                    ]}"#,
            )
            .unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");

        let extra_outputs = apps[0].tools[0]
            .extra_outputs
            .as_ref()
            .expect("extra_outputs should be present");
        assert_eq!(
            extra_outputs,
            &serde_json::json!({"widgetUrl": "https://example.com", "version": 2})
        );
    }

    #[test]
    fn should_have_none_extra_outputs_when_not_specified() {
        let temp = TempDir::new().expect("Could not create temporary directory for test");
        let app_dir = temp.child("NoExtraOutputsApp");
        app_dir
            .child(MANIFEST_FILE_NAME)
            .write_str(
                r#"{"format": "apollo-ai-app-manifest",
                    "version": "1",
                    "hash": "abcdef",
                    "resource": "https://example.com/widget/index.html",
                    "operations": [
                        {"body": "query MyOperation { hello }", "tools": [
                          {"name": "Tool1", "description": "Description for Tool1"}
                        ]}
                    ]}"#,
            )
            .unwrap();
        let apps = load_from_path(
            temp.path(),
            &Schema::parse("type Query { hello: String }", "schema.graphql")
                .unwrap()
                .validate()
                .unwrap(),
            None,
            MutationMode::All,
            false,
            false,
            true,
        )
        .expect("Failed to load apps");

        assert!(apps[0].tools[0].extra_outputs.is_none());
    }
}
