use std::path::Path;
use std::sync::Arc;

use async_trait::async_trait;
use fabro_graphviz::graph::{Graph, Node};
use fabro_types::{StageModelUsage, StageTiming};

use super::agent::{
    CodergenBackend, CodergenResult, OneShotRequest, emit_stage_prompt, extract_status_fields,
    truncate,
};
use super::llm::routing;
use super::{EngineServices, Handler, structured_output};
use crate::context::{Context, WorkflowContext, keys};
use crate::error::Error;
use crate::event::{Emitter, Event};
use crate::outcome::Outcome;

/// Handler for single-shot LLM calls (no tools, no agent loop).
pub struct PromptHandler {
    backend: Option<Box<dyn CodergenBackend>>,
}

impl PromptHandler {
    #[must_use]
    pub fn new(backend: Option<Box<dyn CodergenBackend>>) -> Self {
        Self { backend }
    }
}

#[async_trait]
impl Handler for PromptHandler {
    async fn shutdown(&self, emitter: &Arc<Emitter>) {
        if let Some(backend) = self.backend.as_ref() {
            backend.shutdown(emitter).await;
        }
    }

    async fn simulate(
        &self,
        node: &Node,
        _context: &Context,
        _graph: &Graph,
        _run_dir: &Path,
        _services: &EngineServices,
    ) -> Result<Outcome, Error> {
        Ok(super::agent::simulate_llm_handler(node))
    }

    async fn execute(
        &self,
        node: &Node,
        context: &Context,
        _graph: &Graph,
        _run_dir: &Path,
        services: &EngineServices,
    ) -> Result<Outcome, Error> {
        // 1. Build prompt (prepend fidelity preamble if present)
        let raw_prompt = node.prompt_or_label();
        let preamble = context.preamble();
        let prompt = if preamble.is_empty() {
            raw_prompt.to_string()
        } else {
            format!("{preamble}\n\n{raw_prompt}")
        };

        // 1b. Discover project docs for system prompt when project_memory is enabled
        let system_prompt = if node.project_memory() {
            let working_dir = services.run.sandbox.working_directory();
            let profile_kind = routing::resolve_node_provider_context(
                services.run.catalog.as_ref(),
                &services.run.provider_id,
                &services.run.model,
                node,
            )?
            .profile_kind;
            let docs = match fabro_agent::discover_memory(
                &*services.run.sandbox,
                working_dir,
                working_dir,
                profile_kind,
                &services.run.cancel_token(),
            )
            .await
            {
                Ok(docs) => docs,
                Err(fabro_agent::Error::Interrupted(fabro_agent::InterruptReason::Cancelled)) => {
                    return Err(Error::Cancelled);
                }
                Err(_) => Vec::new(),
            };

            if docs.is_empty() {
                None
            } else {
                Some(
                    docs.into_iter()
                        .map(|doc| doc.content)
                        .collect::<Vec<_>>()
                        .join("\n\n"),
                )
            }
        } else {
            None
        };

        let stage_scope = emit_stage_prompt(
            services,
            context,
            node,
            &prompt,
            StageModelUsage::MODE_PROMPT,
            self.backend.as_deref(),
        )?;

        // 3. Call LLM backend (one_shot)
        let (response_text, stage_usage, backend_files_touched, timing) =
            if let Some(backend) = &self.backend {
                let result = backend
                    .one_shot(OneShotRequest {
                        node,
                        prompt: &prompt,
                        system_prompt: system_prompt.as_deref(),
                        emitter: &services.run.emitter,
                        stage_scope: &stage_scope,
                        sandbox: &services.run.sandbox,
                        cancel_token: services.run.cancel_token(),
                    })
                    .await;
                match result {
                    Ok(CodergenResult::Full(outcome)) => return Ok(*outcome),
                    Ok(CodergenResult::Text {
                        text,
                        usage,
                        files_touched,
                        timing,
                        ..
                    }) => (text, usage, files_touched, timing),
                    Err(Error::Cancelled) => return Err(Error::Cancelled),
                    Err(e) if e.is_retryable() => {
                        return Err(e);
                    }
                    Err(e) => {
                        return Ok(e.to_fail_outcome());
                    }
                }
            } else {
                (
                    format!("[Simulated] Response for stage: {}", node.id),
                    None,
                    Vec::new(),
                    StageTiming::default(),
                )
            };

        let response_model = stage_usage
            .as_ref()
            .map(|usage| usage.model_id().to_string())
            .or_else(|| node.model().map(String::from))
            .unwrap_or_default();
        let response_provider = node
            .provider()
            .map(String::from)
            .or_else(|| Some(services.run.provider_id.to_string()))
            .unwrap_or_default();

        services.run.emitter.emit_scoped(
            &Event::PromptCompleted {
                node_id:  node.id.clone(),
                response: response_text.clone(),
                model:    response_model,
                provider: response_provider,
                billing:  stage_usage.clone(),
            },
            &stage_scope,
        );

        // 4. Build and write status
        let mut outcome = Outcome::success();
        outcome.notes = Some(format!("Stage completed: {}", node.id));
        outcome
            .context_updates
            .insert(keys::LAST_STAGE.to_string(), serde_json::json!(node.id));
        outcome.context_updates.insert(
            keys::LAST_RESPONSE.to_string(),
            serde_json::json!(truncate(&response_text, 200)),
        );
        outcome.context_updates.insert(
            keys::response_key(&node.id),
            serde_json::json!(&response_text),
        );

        if let Some(schema) = structured_output::parse_node_output_schema(node)? {
            if let Ok(validated) =
                structured_output::validate_response_text(&schema, &response_text)
            {
                structured_output::apply_validated_output(node, &schema, &validated, &mut outcome);
            } else {
                let mut failed =
                    structured_output::exhausted_failure_outcome(node.output_retries());
                failed.timing = Some(timing);
                failed.usage = stage_usage;
                failed.files_touched = backend_files_touched;
                return Ok(failed);
            }
        } else {
            extract_status_fields(&response_text, &mut outcome);
        }
        outcome.usage = stage_usage;
        outcome.files_touched = backend_files_touched;
        outcome.timing = Some(timing);

        Ok(outcome)
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;
    use std::time::Duration;

    use fabro_graphviz::graph::AttrValue;
    use fabro_model::{ReasoningEffort, Speed};
    use fabro_store::{Database, RunDatabase, StageId};
    use fabro_types::{fixtures, test_support};
    use object_store::memory::InMemory;
    use tempfile::TempDir;

    use super::*;
    use crate::event::Emitter;
    use crate::handler::agent::CodergenRunRequest;
    use crate::outcome::OutcomeExt;

    fn make_services() -> EngineServices {
        EngineServices::test_default()
    }

    fn test_store() -> Arc<Database> {
        Arc::new(Database::new(
            Arc::new(InMemory::new()),
            "",
            Duration::from_millis(1),
            None,
        ))
    }

    async fn make_services_with_run_store() -> (
        EngineServices,
        RunDatabase,
        crate::event::StoreProgressLogger,
    ) {
        let store = test_store();
        let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
        seed_created(&run_store).await;
        let mut services = EngineServices::test_default();
        services.run = services
            .run
            .with_emitter(Arc::new(Emitter::new(fixtures::RUN_1)))
            .with_run_store(run_store.clone().into());
        let logger = crate::event::StoreProgressLogger::new(run_store.clone());
        logger.register(services.run.emitter.as_ref());
        (services, run_store, logger)
    }

    async fn seed_created(run_store: &RunDatabase) {
        crate::event::append_event(
            run_store,
            &fixtures::RUN_1,
            &crate::event::Event::RunCreated {
                run_id:           fixtures::RUN_1,
                title:            None,
                settings:         serde_json::to_value(fabro_types::WorkflowSettings::default())
                    .unwrap(),
                graph:            serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
                workflow_source:  None,
                labels:           std::collections::BTreeMap::default(),
                source_directory: None,
                workflow_slug:    None,
                automation:       None,
                provenance:       test_support::test_run_provenance(),
                manifest_blob:    None,
                spec_blob:        None,
                git:              None,
                fork_source_ref:  None,
                retried_from:     None,
                parent_id:        None,
                web_url:          None,
            },
        )
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn prompt_handler_simulate() {
        let handler = PromptHandler::new(None);
        let node = Node::new("classify");
        let context = Context::new();
        let graph = Graph::new("test");
        let tmp = TempDir::new().unwrap();

        let outcome = handler
            .simulate(&node, &context, &graph, tmp.path(), &make_services())
            .await
            .unwrap();
        assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded);
        assert_eq!(outcome.notes.as_deref(), Some("[Simulated] classify"));
        assert_eq!(
            outcome
                .context_updates
                .get(crate::context::keys::LAST_STAGE),
            Some(&serde_json::json!("classify"))
        );
        assert!(
            outcome
                .context_updates
                .contains_key(crate::context::keys::LAST_RESPONSE)
        );
        assert_eq!(
            outcome
                .context_updates
                .get(&crate::context::keys::response_key("classify")),
            Some(&serde_json::json!(
                "[Simulated] Response for stage: classify"
            ))
        );
    }

    #[tokio::test]
    async fn prompt_handler_dispatches_to_backend_one_shot() {
        struct OneShotBackend;

        #[async_trait]
        impl CodergenBackend for OneShotBackend {
            async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
                panic!("run() should not be called for prompt handler");
            }

            async fn one_shot(
                &self,
                _request: OneShotRequest<'_>,
            ) -> Result<CodergenResult, Error> {
                Ok(CodergenResult::Text {
                    text:              "one-shot response".to_string(),
                    usage:             None,
                    files_touched:     Vec::new(),
                    last_file_touched: None,
                    timing:            StageTiming::default(),
                })
            }

            fn effective_request_controls(
                &self,
                _node: &Node,
            ) -> Result<crate::handler::llm::api::EffectiveRequestControls, Error> {
                Ok(crate::handler::llm::api::EffectiveRequestControls {
                    reasoning_effort: Some(ReasoningEffort::High),
                    speed:            Some(Speed::Fast),
                })
            }
        }

        let handler = PromptHandler::new(Some(Box::new(OneShotBackend)));
        let mut node = Node::new("classify");
        node.attrs.insert(
            "prompt".to_string(),
            AttrValue::String("Classify this".to_string()),
        );
        let context = Context::new();
        let graph = Graph::new("test");
        let tmp = TempDir::new().unwrap();

        let outcome = handler
            .execute(&node, &context, &graph, tmp.path(), &make_services())
            .await
            .unwrap();
        assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded);

        assert_eq!(
            outcome
                .context_updates
                .get(&crate::context::keys::response_key("classify")),
            Some(&serde_json::json!("one-shot response"))
        );
    }

    #[tokio::test]
    async fn prompt_handler_copies_backend_timing_to_outcome() {
        struct TimingBackend;

        #[async_trait]
        impl CodergenBackend for TimingBackend {
            async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
                panic!("run() should not be called for prompt handler");
            }

            async fn one_shot(
                &self,
                _request: OneShotRequest<'_>,
            ) -> Result<CodergenResult, Error> {
                Ok(CodergenResult::Text {
                    text:              "one-shot response".to_string(),
                    usage:             None,
                    files_touched:     Vec::new(),
                    last_file_touched: None,
                    timing:            StageTiming::new(0, 200, 300),
                })
            }
        }

        let handler = PromptHandler::new(Some(Box::new(TimingBackend)));
        let node = Node::new("classify");
        let context = Context::new();
        let graph = Graph::new("test");
        let tmp = TempDir::new().unwrap();

        let outcome = handler
            .execute(&node, &context, &graph, tmp.path(), &make_services())
            .await
            .unwrap();

        assert_eq!(outcome.timing, Some(StageTiming::new(0, 200, 300)));
    }

    #[tokio::test]
    async fn prompt_handler_custom_output_schema_updates_output_context_key() {
        struct CustomOutputBackend;

        #[async_trait]
        impl CodergenBackend for CustomOutputBackend {
            async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
                panic!("run() should not be called for prompt handler");
            }

            async fn one_shot(
                &self,
                _request: OneShotRequest<'_>,
            ) -> Result<CodergenResult, Error> {
                Ok(CodergenResult::Text {
                    text:              r#"{"passed": true}"#.to_string(),
                    usage:             None,
                    files_touched:     Vec::new(),
                    last_file_touched: None,
                    timing:            StageTiming::default(),
                })
            }
        }

        let handler = PromptHandler::new(Some(Box::new(CustomOutputBackend)));
        let mut node = Node::new("audit");
        node.attrs.insert(
            "output_schema".to_string(),
            AttrValue::String(
                r#"{"type":"object","required":["passed"],"properties":{"passed":{"type":"boolean"}}}"#
                    .to_string(),
            ),
        );
        let context = Context::new();
        let graph = Graph::new("test");
        let tmp = TempDir::new().unwrap();

        let outcome = handler
            .execute(&node, &context, &graph, tmp.path(), &make_services())
            .await
            .unwrap();

        assert_eq!(
            outcome.context_updates.get("output.audit"),
            Some(&serde_json::json!({"passed": true})),
        );
    }

    #[tokio::test]
    async fn prompt_handler_routing_output_schema_requires_valid_routing_json() {
        struct BadRoutingBackend;

        #[async_trait]
        impl CodergenBackend for BadRoutingBackend {
            async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
                panic!("run() should not be called for prompt handler");
            }

            async fn one_shot(
                &self,
                _request: OneShotRequest<'_>,
            ) -> Result<CodergenResult, Error> {
                Ok(CodergenResult::Text {
                    text:              r#"{"outcome": 123}"#.to_string(),
                    usage:             None,
                    files_touched:     Vec::new(),
                    last_file_touched: None,
                    timing:            StageTiming::default(),
                })
            }
        }

        let handler = PromptHandler::new(Some(Box::new(BadRoutingBackend)));
        let mut node = Node::new("route");
        node.attrs.insert(
            "output_schema".to_string(),
            AttrValue::String("routing".to_string()),
        );
        node.attrs
            .insert("output_retries".to_string(), AttrValue::Integer(0));
        let context = Context::new();
        let graph = Graph::new("test");
        let tmp = TempDir::new().unwrap();

        let outcome = handler
            .execute(&node, &context, &graph, tmp.path(), &make_services())
            .await
            .unwrap();

        assert_eq!(outcome.status, crate::outcome::StageOutcome::Failed {
            retry_requested: false,
        });
        assert_eq!(
            outcome.failure_reason(),
            Some("output schema validation failed after 0 repair attempt(s)")
        );
    }

    #[tokio::test]
    async fn prompt_handler_projects_provider_used_from_prompt_events() {
        struct ProviderOneShotBackend;

        #[async_trait]
        impl CodergenBackend for ProviderOneShotBackend {
            async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
                panic!("run() should not be called for prompt handler");
            }

            async fn one_shot(
                &self,
                _request: OneShotRequest<'_>,
            ) -> Result<CodergenResult, Error> {
                Ok(CodergenResult::Text {
                    text:              "one-shot response".to_string(),
                    usage:             None,
                    files_touched:     Vec::new(),
                    last_file_touched: None,
                    timing:            StageTiming::default(),
                })
            }

            fn effective_request_controls(
                &self,
                _node: &Node,
            ) -> Result<crate::handler::llm::api::EffectiveRequestControls, Error> {
                Ok(crate::handler::llm::api::EffectiveRequestControls {
                    reasoning_effort: Some(ReasoningEffort::High),
                    speed:            Some(Speed::Fast),
                })
            }
        }

        let handler = PromptHandler::new(Some(Box::new(ProviderOneShotBackend)));
        let mut node = Node::new("classify");
        node.attrs.insert(
            "prompt".to_string(),
            AttrValue::String("Classify this".to_string()),
        );
        let context = Context::new();
        let graph = Graph::new("test");
        let tmp = TempDir::new().unwrap();
        let (services, run_store, logger) = make_services_with_run_store().await;

        handler
            .execute(&node, &context, &graph, tmp.path(), &services)
            .await
            .unwrap();
        logger.flush().await;

        let state = run_store.state().await.unwrap();
        let node_state = state.stage(&StageId::new("classify", 1)).unwrap();
        let provider_used = node_state.provider_used.as_ref().unwrap();
        assert_eq!(provider_used.mode, StageModelUsage::MODE_PROMPT);
        assert_eq!(provider_used.reasoning_effort, Some(ReasoningEffort::High));
        assert_eq!(provider_used.speed, Some(Speed::Fast));
    }

    struct OneShotCapturingBackend {
        captured_prompt:        Arc<std::sync::Mutex<Option<String>>>,
        captured_system_prompt: Arc<std::sync::Mutex<Option<Option<String>>>>,
    }

    #[async_trait]
    impl CodergenBackend for OneShotCapturingBackend {
        async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
            panic!("run() should not be called for prompt handler");
        }

        async fn one_shot(&self, request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
            *self.captured_prompt.lock().unwrap() = Some(request.prompt.to_string());
            *self.captured_system_prompt.lock().unwrap() =
                Some(request.system_prompt.map(String::from));
            Ok(CodergenResult::Text {
                text:              "classified".to_string(),
                usage:             None,
                files_touched:     Vec::new(),
                last_file_touched: None,
                timing:            StageTiming::default(),
            })
        }
    }

    #[tokio::test]
    async fn prompt_handler_prepends_preamble() {
        use std::sync::Mutex;

        let captured = Arc::new(Mutex::new(None));
        let backend = OneShotCapturingBackend {
            captured_prompt:        captured.clone(),
            captured_system_prompt: Arc::new(Mutex::new(None)),
        };
        let handler = PromptHandler::new(Some(Box::new(backend)));

        let mut node = Node::new("classify");
        node.attrs.insert(
            "prompt".to_string(),
            AttrValue::String("Classify this".to_string()),
        );
        let context = Context::new();
        context.set(
            keys::CURRENT_PREAMBLE,
            serde_json::json!("Prior output here"),
        );
        let graph = Graph::new("test");
        let tmp = TempDir::new().unwrap();

        handler
            .execute(&node, &context, &graph, tmp.path(), &make_services())
            .await
            .unwrap();

        let prompt = captured.lock().unwrap().clone().unwrap();
        assert!(
            prompt.starts_with("Prior output here"),
            "one_shot prompt should start with preamble, got: {prompt}"
        );
        assert!(prompt.ends_with("Classify this"));
    }

    #[tokio::test]
    async fn prompt_handler_passes_system_prompt_when_project_memory_enabled() {
        use std::sync::Mutex;

        let captured_sys = Arc::new(Mutex::new(None));
        let backend = OneShotCapturingBackend {
            captured_prompt:        Arc::new(Mutex::new(None)),
            captured_system_prompt: captured_sys.clone(),
        };
        let handler = PromptHandler::new(Some(Box::new(backend)));

        // project_memory defaults to true; sandbox working_directory points to cwd
        // which likely has no AGENTS.md/CLAUDE.md, so system_prompt should be None
        let mut node = Node::new("classify");
        node.attrs.insert(
            "prompt".to_string(),
            AttrValue::String("Classify this".to_string()),
        );
        let context = Context::new();
        let graph = Graph::new("test");
        let tmp = TempDir::new().unwrap();

        handler
            .execute(&node, &context, &graph, tmp.path(), &make_services())
            .await
            .unwrap();

        // With project_memory=true (default), one_shot is called (system_prompt
        // captured)
        let sys = captured_sys.lock().unwrap().clone();
        assert!(sys.is_some(), "one_shot should have been called");
    }

    #[tokio::test]
    async fn prompt_handler_project_memory_uses_model_agent_profile_override() {
        use std::sync::Mutex;

        let captured_sys = Arc::new(Mutex::new(None));
        let backend = OneShotCapturingBackend {
            captured_prompt:        Arc::new(Mutex::new(None)),
            captured_system_prompt: captured_sys.clone(),
        };
        let handler = PromptHandler::new(Some(Box::new(backend)));
        let workspace = TempDir::new().unwrap();
        tokio::fs::write(workspace.path().join("CLAUDE.md"), "anthropic memory")
            .await
            .unwrap();
        let overrides: fabro_model::catalog::LlmCatalogSettings = toml::from_str(
            r#"
[providers.acme]
adapter = "openai_compatible"
agent_profile = "openai"
base_url = "https://api.acme.test/v1"

[models.acme-claude]
provider = "acme"
display_name = "Acme Claude"
family = "claude"
default = true
agent_profile = "anthropic"
aliases = ["ac"]

[models.acme-claude.limits]
context_window = 1000

[models.acme-claude.features]
tools = true
vision = false
reasoning = false
"#,
        )
        .unwrap();
        let catalog =
            Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&overrides).unwrap());
        let mut services = make_services();
        services.run = services
            .run
            .with_sandbox(Arc::new(fabro_agent::LocalSandbox::new(
                workspace.path().to_path_buf(),
            )))
            .with_catalog_context(
                Arc::clone(&catalog),
                fabro_model::ProviderId::new("acme"),
                "acme-claude".to_string(),
            );

        let mut node = Node::new("classify");
        node.attrs.insert(
            "prompt".to_string(),
            AttrValue::String("Classify this".to_string()),
        );
        node.attrs
            .insert("model".to_string(), AttrValue::String("ac".to_string()));
        let context = Context::new();
        let graph = Graph::new("test");

        handler
            .execute(&node, &context, &graph, workspace.path(), &services)
            .await
            .unwrap();

        let sys = captured_sys.lock().unwrap().clone();
        assert!(
            sys.flatten()
                .is_some_and(|system_prompt| system_prompt.contains("anthropic memory")),
            "project memory should use model-level Anthropic profile and read CLAUDE.md"
        );
    }

    #[tokio::test]
    async fn prompt_handler_project_memory_uses_default_model_profile_for_provider_attr() {
        use std::sync::Mutex;

        let captured_sys = Arc::new(Mutex::new(None));
        let backend = OneShotCapturingBackend {
            captured_prompt:        Arc::new(Mutex::new(None)),
            captured_system_prompt: captured_sys.clone(),
        };
        let handler = PromptHandler::new(Some(Box::new(backend)));
        let workspace = TempDir::new().unwrap();
        tokio::fs::write(workspace.path().join("CLAUDE.md"), "anthropic memory")
            .await
            .unwrap();
        let overrides: fabro_model::catalog::LlmCatalogSettings = toml::from_str(
            r#"
[providers.acme]
adapter = "openai_compatible"
agent_profile = "openai"
base_url = "https://api.acme.test/v1"

[models.acme-claude]
provider = "acme"
display_name = "Acme Claude"
family = "claude"
default = true
agent_profile = "anthropic"

[models.acme-claude.limits]
context_window = 1000

[models.acme-claude.features]
tools = true
vision = false
reasoning = false
"#,
        )
        .unwrap();
        let catalog =
            Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&overrides).unwrap());
        let mut services = make_services();
        services.run = services
            .run
            .with_sandbox(Arc::new(fabro_agent::LocalSandbox::new(
                workspace.path().to_path_buf(),
            )))
            .with_catalog_context(
                Arc::clone(&catalog),
                fabro_model::ProviderId::new("acme"),
                "acme-claude".to_string(),
            );

        let mut node = Node::new("classify");
        node.attrs.insert(
            "prompt".to_string(),
            AttrValue::String("Classify this".to_string()),
        );
        node.attrs.insert(
            "provider".to_string(),
            AttrValue::String("acme".to_string()),
        );
        let context = Context::new();
        let graph = Graph::new("test");

        handler
            .execute(&node, &context, &graph, workspace.path(), &services)
            .await
            .unwrap();

        let sys = captured_sys.lock().unwrap().clone();
        assert!(
            sys.flatten()
                .is_some_and(|system_prompt| system_prompt.contains("anthropic memory")),
            "project memory should use the default model's Anthropic profile when only the matching provider is set"
        );
    }

    #[tokio::test]
    async fn prompt_handler_passes_none_system_prompt_when_project_memory_false() {
        use std::sync::Mutex;

        let captured_sys = Arc::new(Mutex::new(None));
        let backend = OneShotCapturingBackend {
            captured_prompt:        Arc::new(Mutex::new(None)),
            captured_system_prompt: captured_sys.clone(),
        };
        let handler = PromptHandler::new(Some(Box::new(backend)));

        let mut node = Node::new("classify");
        node.attrs.insert(
            "prompt".to_string(),
            AttrValue::String("Classify this".to_string()),
        );
        node.attrs
            .insert("project_memory".to_string(), AttrValue::Boolean(false));
        let context = Context::new();
        let graph = Graph::new("test");
        let tmp = TempDir::new().unwrap();

        handler
            .execute(&node, &context, &graph, tmp.path(), &make_services())
            .await
            .unwrap();

        let sys = captured_sys.lock().unwrap().clone();
        assert_eq!(
            sys,
            Some(None),
            "system_prompt should be None when project_memory=false"
        );
    }
}
