//! Application builder for initializing core IronClaw components.
//!
//! Extracts the mechanical initialization phases from `main.rs` into a
//! reusable builder so that:
//!
//! - Tests can construct a full `AppComponents` without wiring channels
//! - Main stays focused on CLI dispatch and channel setup
//! - Each init phase is independently testable

use std::sync::Arc;

use crate::agent::SessionManager as AgentSessionManager;
use crate::channels::web::log_layer::LogBroadcaster;
use crate::config::Config;
use crate::context::ContextManager;
use crate::db::Database;
use crate::extensions::ExtensionManager;
use crate::hooks::HookRegistry;
use crate::llm::{LlmProvider, RecordingLlm, SessionManager};
use crate::safety::SafetyLayer;
use crate::secrets::SecretsStore;
use crate::skills::SkillRegistry;
use crate::skills::catalog::SkillCatalog;
use crate::tools::ToolRegistry;
use crate::tools::mcp::{McpProcessManager, McpSessionManager};
use crate::tools::wasm::SharedCredentialRegistry;
use crate::tools::wasm::WasmToolRuntime;
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace};

/// Fully initialized application components, ready for channel wiring
/// and agent construction.
pub struct AppComponents {
    /// The (potentially mutated) config after DB reload and secret injection.
    pub config: Config,
    pub db: Option<Arc<dyn Database>>,
    pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
    pub llm: Arc<dyn LlmProvider>,
    pub cheap_llm: Option<Arc<dyn LlmProvider>>,
    pub safety: Arc<SafetyLayer>,
    pub tools: Arc<ToolRegistry>,
    pub embeddings: Option<Arc<dyn EmbeddingProvider>>,
    pub workspace: Option<Arc<Workspace>>,
    pub extension_manager: Option<Arc<ExtensionManager>>,
    pub mcp_session_manager: Arc<McpSessionManager>,
    pub mcp_process_manager: Arc<McpProcessManager>,
    pub wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
    pub log_broadcaster: Arc<LogBroadcaster>,
    pub context_manager: Arc<ContextManager>,
    pub hooks: Arc<HookRegistry>,
    /// Shared thread/session manager used by the standard agent runtime.
    pub agent_session_manager: Arc<AgentSessionManager>,
    pub skill_registry: Option<Arc<std::sync::RwLock<SkillRegistry>>>,
    pub skill_catalog: Option<Arc<SkillCatalog>>,
    pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
    pub recording_handle: Option<Arc<RecordingLlm>>,
    pub session: Arc<SessionManager>,
    pub catalog_entries: Vec<crate::extensions::RegistryEntry>,
    pub dev_loaded_tool_names: Vec<String>,
    pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
}

/// Options that control optional init phases.
#[derive(Default)]
pub struct AppBuilderFlags {
    pub no_db: bool,
}

/// Builder that orchestrates the 5 mechanical init phases.
pub struct AppBuilder {
    config: Config,
    flags: AppBuilderFlags,
    toml_path: Option<std::path::PathBuf>,
    session: Arc<SessionManager>,
    log_broadcaster: Arc<LogBroadcaster>,

    // Accumulated state
    db: Option<Arc<dyn Database>>,
    secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,

    // Test overrides
    llm_override: Option<Arc<dyn LlmProvider>>,

    // Backend-specific handles needed by secrets store
    handles: Option<crate::db::DatabaseHandles>,
}

impl AppBuilder {
    /// Create a new builder.
    ///
    /// The `session` and `log_broadcaster` are created before the builder
    /// because tracing must be initialized before any init phase runs,
    /// and the log broadcaster is part of the tracing layer.
    pub fn new(
        config: Config,
        flags: AppBuilderFlags,
        toml_path: Option<std::path::PathBuf>,
        session: Arc<SessionManager>,
        log_broadcaster: Arc<LogBroadcaster>,
    ) -> Self {
        Self {
            config,
            flags,
            toml_path,
            session,
            log_broadcaster,
            db: None,
            secrets_store: None,
            llm_override: None,
            handles: None,
        }
    }

    /// Inject a pre-created database, skipping `init_database()`.
    pub fn with_database(&mut self, db: Arc<dyn Database>) {
        self.db = Some(db);
    }

    /// Inject a pre-created LLM provider, skipping `init_llm()`.
    pub fn with_llm(&mut self, llm: Arc<dyn LlmProvider>) {
        self.llm_override = Some(llm);
    }

    /// Phase 1: Initialize database backend.
    ///
    /// Creates the database connection, runs migrations, reloads config
    /// from DB, attaches DB to session manager, and cleans up stale jobs.
    pub async fn init_database(&mut self) -> Result<(), anyhow::Error> {
        if self.db.is_some() {
            tracing::debug!("Database already provided, skipping init_database()");
            return Ok(());
        }

        if self.flags.no_db {
            tracing::warn!("Running without database connection");
            return Ok(());
        }

        let (db, handles) = crate::db::connect_with_handles(&self.config.database)
            .await
            .map_err(|e| anyhow::anyhow!("{}", e))?;
        self.handles = Some(handles);

        // Post-init: migrate disk config, reload config from DB, attach session, cleanup
        if let Err(e) =
            crate::bootstrap::migrate_disk_to_db(db.as_ref(), &self.config.owner_id).await
        {
            tracing::warn!("Disk-to-DB settings migration failed: {}", e);
        }

        let toml_path = self.toml_path.as_deref();
        match Config::from_db_with_toml(db.as_ref(), &self.config.owner_id, toml_path).await {
            Ok(db_config) => {
                self.config = db_config;
                tracing::debug!("Configuration reloaded from database");
            }
            Err(e) => {
                tracing::warn!(
                    "Failed to reload config from DB, keeping env-based config: {}",
                    e
                );
            }
        }

        self.session
            .attach_store(db.clone(), &self.config.owner_id)
            .await;

        // Fire-and-forget housekeeping — no need to block startup.
        let db_cleanup = db.clone();
        tokio::spawn(async move {
            if let Err(e) = db_cleanup.cleanup_stale_sandbox_jobs().await {
                tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
            }
        });

        self.db = Some(db);
        Ok(())
    }

    /// Phase 2: Create secrets store.
    ///
    /// Requires a master key and a backend-specific DB handle. After creating
    /// the store, injects any encrypted LLM API keys into the config overlay
    /// and re-resolves config.
    pub async fn init_secrets(&mut self) -> Result<(), anyhow::Error> {
        let master_key = match self.config.secrets.master_key() {
            Some(k) => k,
            None => {
                // No secrets DB available, but we can still load tokens from
                // OS credential stores (e.g., Anthropic OAuth via Claude Code's
                // macOS Keychain / Linux ~/.claude/.credentials.json).
                crate::config::inject_os_credentials();

                // Consume unused handles
                self.handles.take();

                // Re-resolve only the LLM config with OS credentials.
                let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
                    self.db.as_ref().map(|db| db.as_ref() as _);
                let toml_path = self.toml_path.as_deref();
                let owner_id = self.config.owner_id.clone();
                if let Err(e) = self
                    .config
                    .re_resolve_llm(store, &owner_id, toml_path)
                    .await
                {
                    tracing::warn!(
                        "Failed to re-resolve LLM config after OS credential injection: {e}"
                    );
                }

                return Ok(());
            }
        };

        let crypto = match crate::secrets::SecretsCrypto::new(master_key.clone()) {
            Ok(c) => Arc::new(c),
            Err(e) => {
                tracing::warn!("Failed to initialize secrets crypto: {}", e);
                self.handles.take();
                return Ok(());
            }
        };

        // Fallback covers the no-database path where `init_database` returned
        // early before populating `self.handles`.
        let empty_handles = crate::db::DatabaseHandles::default();
        let handles = self.handles.as_ref().unwrap_or(&empty_handles);
        let store = crate::secrets::create_secrets_store(crypto, handles);

        if let Some(ref secrets) = store {
            // Migrate any plaintext API keys from the settings table to the
            // encrypted secrets store. Idempotent — safe to run on every startup.
            if let Some(ref db) = self.db {
                crate::config::migrate_plaintext_llm_keys(
                    db.as_ref(),
                    secrets.as_ref(),
                    &self.config.owner_id,
                )
                .await;
            }

            // Inject LLM API keys from encrypted storage
            crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), &self.config.owner_id)
                .await;

            // Re-resolve only the LLM config with newly available keys,
            // including keys hydrated from the secrets store.
            let settings_store: Option<&(dyn crate::db::SettingsStore + Sync)> =
                self.db.as_ref().map(|db| db.as_ref() as _);
            let toml_path = self.toml_path.as_deref();
            let owner_id = self.config.owner_id.clone();
            if let Err(e) = self
                .config
                .re_resolve_llm_with_secrets(
                    settings_store,
                    &owner_id,
                    toml_path,
                    Some(secrets.as_ref()),
                )
                .await
            {
                tracing::warn!("Failed to re-resolve LLM config after secret injection: {e}");
            }
        }

        self.secrets_store = store;
        Ok(())
    }

    /// Phase 3: Initialize LLM provider chain.
    ///
    /// Delegates to `build_provider_chain` which applies all decorators
    /// (retry, smart routing, failover, circuit breaker, response cache).
    #[allow(clippy::type_complexity)]
    pub async fn init_llm(
        &self,
    ) -> Result<
        (
            Arc<dyn LlmProvider>,
            Option<Arc<dyn LlmProvider>>,
            Option<Arc<RecordingLlm>>,
        ),
        anyhow::Error,
    > {
        let (llm, cheap_llm, recording_handle) =
            crate::llm::build_provider_chain(&self.config.llm, self.session.clone()).await?;
        Ok((llm, cheap_llm, recording_handle))
    }

    /// Phase 4: Initialize safety, tools, embeddings, and workspace.
    pub async fn init_tools(
        &self,
        llm: &Arc<dyn LlmProvider>,
    ) -> Result<
        (
            Arc<SafetyLayer>,
            Arc<ToolRegistry>,
            Option<Arc<dyn EmbeddingProvider>>,
            Option<Arc<Workspace>>,
            Option<Arc<dyn crate::tools::SoftwareBuilder>>,
        ),
        anyhow::Error,
    > {
        let safety = Arc::new(SafetyLayer::new(&self.config.safety));
        tracing::debug!("Safety layer initialized");

        // Initialize tool registry with credential injection support
        let credential_registry = Arc::new(SharedCredentialRegistry::new());
        let tools = if let Some(ref ss) = self.secrets_store {
            Arc::new(
                ToolRegistry::new()
                    .with_credentials(Arc::clone(&credential_registry), Arc::clone(ss)),
            )
        } else {
            Arc::new(ToolRegistry::new())
        };
        tools.register_builtin_tools();
        tools.register_tool_info();

        if let Some(ref ss) = self.secrets_store {
            tools.register_secrets_tools(Arc::clone(ss));
        }

        // Create embeddings provider using the unified method
        let embeddings = self
            .config
            .embeddings
            .create_provider(&self.config.llm.nearai.base_url, self.session.clone());

        // Register memory tools if database is available
        let workspace_user_id = self
            .config
            .channels
            .gateway
            .as_ref()
            .map(|gw| gw.user_id.as_str())
            .unwrap_or("default");
        let workspace = if let Some(ref db) = self.db {
            let emb_cache_config = EmbeddingCacheConfig {
                max_entries: self.config.embeddings.cache_size,
            };
            let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
                .with_search_config(&self.config.search);

            if let Some(ref emb) = embeddings {
                ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config.clone());
            }

            // Wire workspace-level settings (read scopes, memory layers)
            if !self.config.workspace.read_scopes.is_empty() {
                ws = ws.with_additional_read_scopes(self.config.workspace.read_scopes.clone());
                tracing::info!(
                    user_id = workspace_user_id,
                    read_scopes = ?ws.read_user_ids(),
                    "Workspace configured with multi-scope reads"
                );
            }
            ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
            let ws = Arc::new(ws);

            // Detect multi-tenant mode: when GATEWAY_USER_TOKENS is configured,
            // each authenticated user needs their own workspace scope. Use
            // WorkspacePool (which implements WorkspaceResolver) to create
            // per-user workspaces on demand instead of sharing the startup
            // workspace across all users.
            let is_multi_tenant = self
                .config
                .channels
                .gateway
                .as_ref()
                .is_some_and(|gw| gw.user_tokens.is_some());

            if is_multi_tenant {
                let pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
                    Arc::clone(db),
                    embeddings.clone(),
                    emb_cache_config,
                    self.config.search.clone(),
                    self.config.workspace.clone(),
                ));
                tools.register_memory_tools_with_resolver(pool);
                tracing::info!(
                    "Memory tools configured with per-user workspace resolver (multi-tenant mode)"
                );
            } else {
                tools.register_memory_tools(Arc::clone(&ws));
            }

            Some(ws)
        } else {
            None
        };

        // Register image/vision tools if we have a workspace and LLM API credentials
        if workspace.is_some() {
            let (api_base, api_key_opt) = if let Some(ref provider) = self.config.llm.provider {
                (
                    provider.base_url.clone(),
                    provider.api_key.as_ref().map(|s| {
                        use secrecy::ExposeSecret;
                        s.expose_secret().to_string()
                    }),
                )
            } else {
                (
                    self.config.llm.nearai.base_url.clone(),
                    self.config.llm.nearai.api_key.as_ref().map(|s| {
                        use secrecy::ExposeSecret;
                        s.expose_secret().to_string()
                    }),
                )
            };

            if let Some(api_key) = api_key_opt {
                // Check for image generation models
                let model_name = self
                    .config
                    .llm
                    .provider
                    .as_ref()
                    .map(|p| p.model.clone())
                    .unwrap_or_else(|| self.config.llm.nearai.model.clone());
                let models = vec![model_name.clone()];
                let gen_model = crate::llm::image_models::suggest_image_model(&models)
                    .unwrap_or("flux-1.1-pro")
                    .to_string();
                tools.register_image_tools(api_base.clone(), api_key.clone(), gen_model, None);

                // Check for vision models
                let vision_model = crate::llm::vision_models::suggest_vision_model(&models)
                    .unwrap_or(&model_name)
                    .to_string();
                tools.register_vision_tools(api_base, api_key, vision_model, None);
            }
        }

        // Register builder tool if enabled
        let builder = if self.config.builder.enabled
            && (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
        {
            let b = tools
                .register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
                .await;
            tracing::debug!("Builder mode enabled");
            Some(b)
        } else {
            None
        };

        Ok((safety, tools, embeddings, workspace, builder))
    }

    /// Phase 5: Load WASM tools, MCP servers, and create extension manager.
    pub async fn init_extensions(
        &self,
        tools: &Arc<ToolRegistry>,
        hooks: &Arc<HookRegistry>,
    ) -> Result<
        (
            Arc<McpSessionManager>,
            Arc<McpProcessManager>,
            Option<Arc<WasmToolRuntime>>,
            Option<Arc<ExtensionManager>>,
            Vec<crate::extensions::RegistryEntry>,
            Vec<String>,
        ),
        anyhow::Error,
    > {
        use crate::tools::mcp::config::load_mcp_servers_from_db;
        use crate::tools::wasm::{WasmToolLoader, load_dev_tools};

        let mcp_session_manager = Arc::new(McpSessionManager::new());
        let mcp_process_manager = Arc::new(McpProcessManager::new());

        // Create WASM tool runtime eagerly so extensions installed after startup
        // (e.g. via the web UI) can still be activated. The tools directory is only
        // needed when loading modules, not for engine initialisation.
        let wasm_tool_runtime: Option<Arc<WasmToolRuntime>> = if self.config.wasm.enabled {
            WasmToolRuntime::new(self.config.wasm.to_runtime_config())
                .map(Arc::new)
                .map_err(|e| tracing::warn!("Failed to initialize WASM runtime: {}", e))
                .ok()
        } else {
            None
        };

        // Load WASM tools and MCP servers concurrently
        let wasm_tools_future = {
            let wasm_tool_runtime = wasm_tool_runtime.clone();
            let secrets_store = self.secrets_store.clone();
            let tools = Arc::clone(tools);
            let wasm_config = self.config.wasm.clone();
            async move {
                let mut dev_loaded_tool_names: Vec<String> = Vec::new();

                if let Some(ref runtime) = wasm_tool_runtime {
                    let mut loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools));
                    if let Some(ref secrets) = secrets_store {
                        loader = loader.with_secrets_store(Arc::clone(secrets));
                    }

                    match loader.load_from_dir(&wasm_config.tools_dir).await {
                        Ok(results) => {
                            if !results.loaded.is_empty() {
                                tracing::debug!(
                                    "Loaded {} WASM tools from {}",
                                    results.loaded.len(),
                                    wasm_config.tools_dir.display()
                                );
                            }
                            for (path, err) in &results.errors {
                                tracing::warn!(
                                    "Failed to load WASM tool {}: {}",
                                    path.display(),
                                    err
                                );
                            }
                        }
                        Err(e) => {
                            tracing::warn!("Failed to scan WASM tools directory: {}", e);
                        }
                    }

                    match load_dev_tools(&loader, &wasm_config.tools_dir).await {
                        Ok(results) => {
                            dev_loaded_tool_names.extend(results.loaded.iter().cloned());
                            if !dev_loaded_tool_names.is_empty() {
                                tracing::debug!(
                                    "Loaded {} dev WASM tools from build artifacts",
                                    dev_loaded_tool_names.len()
                                );
                            }
                        }
                        Err(e) => {
                            tracing::debug!("No dev WASM tools found: {}", e);
                        }
                    }
                }

                dev_loaded_tool_names
            }
        };

        let mcp_servers_future = {
            let secrets_store = self.secrets_store.clone();
            let db = self.db.clone();
            let tools = Arc::clone(tools);
            let mcp_sm = Arc::clone(&mcp_session_manager);
            let pm = Arc::clone(&mcp_process_manager);
            let owner_id = self.config.owner_id.clone();
            async move {
                let servers_result = if let Some(ref d) = db {
                    load_mcp_servers_from_db(d.as_ref(), &owner_id).await
                } else {
                    crate::tools::mcp::config::load_mcp_servers().await
                };
                match servers_result {
                    Ok(servers) => {
                        let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
                        if !enabled.is_empty() {
                            tracing::debug!(
                                "Loading {} configured MCP server(s)...",
                                enabled.len()
                            );
                        }

                        let mut join_set = tokio::task::JoinSet::new();
                        for server in enabled {
                            let mcp_sm = Arc::clone(&mcp_sm);
                            let secrets = secrets_store.clone();
                            let tools = Arc::clone(&tools);
                            let pm = Arc::clone(&pm);
                            let owner_id = owner_id.clone();

                            join_set.spawn(async move {
                                let server_name = server.name.clone();

                                let client = match crate::tools::mcp::create_client_from_config(
                                    server,
                                    &mcp_sm,
                                    &pm,
                                    secrets,
                                    &owner_id,
                                )
                                .await
                                {
                                    Ok(c) => c,
                                    Err(e) => {
                                        tracing::warn!(
                                            "Failed to create MCP client for '{}': {}",
                                            server_name,
                                            e
                                        );
                                        return None;
                                    }
                                };

                                match client.list_tools().await {
                                    Ok(mcp_tools) => {
                                        let tool_count = mcp_tools.len();
                                        match client.create_tools().await {
                                            Ok(tool_impls) => {
                                                for tool in tool_impls {
                                                    tools.register(tool).await;
                                                }
                                                tracing::debug!(
                                                    "Loaded {} tools from MCP server '{}'",
                                                    tool_count,
                                                    server_name
                                                );
                                                return Some((
                                                    server_name,
                                                    Arc::new(client),
                                                ));
                                            }
                                            Err(e) => {
                                                tracing::warn!(
                                                    "Failed to create tools from MCP server '{}': {}",
                                                    server_name,
                                                    e
                                                );
                                            }
                                        }
                                    }
                                    Err(e) => {
                                        let err_str = e.to_string();
                                        if err_str.contains("401")
                                            || err_str.contains("authentication")
                                        {
                                            tracing::warn!(
                                                "MCP server '{}' requires authentication. \
                                                 Run: ironclaw mcp auth {}",
                                                server_name,
                                                server_name
                                            );
                                        } else {
                                            tracing::warn!(
                                                "Failed to connect to MCP server '{}': {}",
                                                server_name,
                                                e
                                            );
                                        }
                                    }
                                }
                                None
                            });
                        }

                        let mut startup_clients = Vec::new();
                        while let Some(result) = join_set.join_next().await {
                            match result {
                                Ok(Some(client_pair)) => {
                                    startup_clients.push(client_pair);
                                }
                                Ok(None) => {}
                                Err(e) => {
                                    if e.is_panic() {
                                        tracing::error!("MCP server loading task panicked: {}", e);
                                    } else {
                                        tracing::warn!("MCP server loading task failed: {}", e);
                                    }
                                }
                            }
                        }
                        return startup_clients;
                    }
                    Err(e) => {
                        if matches!(
                            e,
                            crate::tools::mcp::config::ConfigError::InvalidConfig { .. }
                                | crate::tools::mcp::config::ConfigError::Json(_)
                        ) {
                            tracing::warn!(
                                "MCP server configuration is invalid: {}. \
                                 Fix or remove the corrupted config.",
                                e
                            );
                        } else {
                            tracing::debug!("No MCP servers configured ({})", e);
                        }
                    }
                }
                Vec::new()
            }
        };

        let (dev_loaded_tool_names, startup_mcp_clients) =
            tokio::join!(wasm_tools_future, mcp_servers_future);

        // Load registry catalog entries for extension discovery
        let mut catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() {
            Ok(catalog) => {
                let entries: Vec<_> = catalog
                    .all()
                    .iter()
                    .filter_map(|m| m.to_registry_entry())
                    .collect();
                tracing::debug!(
                    count = entries.len(),
                    "Loaded registry catalog entries for extension discovery"
                );
                entries
            }
            Err(e) => {
                tracing::warn!("Failed to load registry catalog: {}", e);
                Vec::new()
            }
        };

        // Append builtin entries (e.g. channel-relay integrations) so they appear
        // in the web UI's available extensions list.
        let builtin = crate::extensions::registry::builtin_entries();
        for entry in builtin {
            if !catalog_entries.iter().any(|e| e.name == entry.name) {
                catalog_entries.push(entry);
            }
        }

        // Create extension manager. Use ephemeral in-memory secrets if no
        // persistent store is configured (listing/install/activate still work).
        let ext_secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> = if let Some(ref s) =
            self.secrets_store
        {
            Arc::clone(s)
        } else {
            use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
            let ephemeral_key =
                secrecy::SecretString::from(crate::secrets::keychain::generate_master_key_hex());
            let crypto = Arc::new(SecretsCrypto::new(ephemeral_key).expect("ephemeral crypto"));
            tracing::debug!("Using ephemeral in-memory secrets store for extension manager");
            Arc::new(InMemorySecretsStore::new(crypto))
        };
        let extension_manager = {
            let manager = Arc::new(ExtensionManager::new(
                Arc::clone(&mcp_session_manager),
                Arc::clone(&mcp_process_manager),
                ext_secrets,
                Arc::clone(tools),
                Some(Arc::clone(hooks)),
                wasm_tool_runtime.clone(),
                self.config.wasm.tools_dir.clone(),
                self.config.channels.wasm_channels_dir.clone(),
                self.config.tunnel.public_url.clone(),
                self.config.owner_id.clone(),
                self.db.clone(),
                catalog_entries.clone(),
            ));
            tools.register_extension_tools(Arc::clone(&manager));
            tracing::debug!("Extension manager initialized with in-chat discovery tools");

            if !startup_mcp_clients.is_empty() {
                tracing::info!(
                    count = startup_mcp_clients.len(),
                    "Injecting startup MCP clients into extension manager"
                );
                for (name, client) in startup_mcp_clients {
                    manager.inject_mcp_client(name, client).await;
                }
            }

            Some(manager)
        };

        // register_builder_tool() already calls register_dev_tools() internally,
        // so only register them here when the builder didn't already do it.
        let builder_registered_dev_tools = self.config.builder.enabled
            && (self.config.agent.allow_local_tools || !self.config.sandbox.enabled);
        if self.config.agent.allow_local_tools && !builder_registered_dev_tools {
            tools.register_dev_tools();
        }

        Ok((
            mcp_session_manager,
            mcp_process_manager,
            wasm_tool_runtime,
            extension_manager,
            catalog_entries,
            dev_loaded_tool_names,
        ))
    }

    /// Run all init phases in order and return the assembled components.
    pub async fn build_all(mut self) -> Result<AppComponents, anyhow::Error> {
        self.init_database().await?;
        self.init_secrets().await?;

        // Post-init validation: backends with dedicated config (nearai, gemini_oauth,
        // bedrock, openai_codex) handle their own credential resolution. For registry-based
        // backends, fail early if no provider config was resolved.
        if !matches!(
            self.config.llm.backend.as_str(),
            "nearai" | "gemini_oauth" | "bedrock" | "openai_codex"
        ) && self.config.llm.provider.is_none()
        {
            let backend = &self.config.llm.backend;
            anyhow::bail!(
                "LLM_BACKEND={backend} is configured but no credentials were found. \
                 Set the appropriate API key environment variable or run the setup wizard."
            );
        }

        let (llm, cheap_llm, recording_handle) = if let Some(llm) = self.llm_override.take() {
            (llm, None, None)
        } else {
            self.init_llm().await?
        };
        let (safety, tools, embeddings, workspace, builder) = self.init_tools(&llm).await?;

        // Create hook registry early so runtime extension activation can register hooks.
        let hooks = Arc::new(HookRegistry::new());
        let agent_session_manager =
            Arc::new(AgentSessionManager::new().with_hooks(Arc::clone(&hooks)));

        let (
            mcp_session_manager,
            mcp_process_manager,
            wasm_tool_runtime,
            extension_manager,
            catalog_entries,
            dev_loaded_tool_names,
        ) = self.init_extensions(&tools, &hooks).await?;

        // Load bootstrap-completed flag from settings so that existing users
        // who already completed onboarding don't re-get bootstrap injection.
        if let Some(ref ws) = workspace {
            let toml_path = crate::settings::Settings::default_toml_path();
            if let Ok(Some(settings)) = crate::settings::Settings::load_toml(&toml_path)
                && settings.profile_onboarding_completed
            {
                ws.mark_bootstrap_completed();
            }
        }

        // Seed workspace and backfill embeddings
        if let Some(ref ws) = workspace {
            // Import workspace files from disk FIRST if WORKSPACE_IMPORT_DIR is set.
            // This lets Docker images / deployment scripts ship customized
            // workspace templates (e.g., AGENTS.md, TOOLS.md) that override
            // the generic seeds. Only imports files that don't already exist
            // in the database — never overwrites user edits.
            //
            // Runs before seed_if_empty() so that custom templates take priority
            // over generic seeds. seed_if_empty() then fills any remaining gaps.
            if let Ok(import_dir) = std::env::var("WORKSPACE_IMPORT_DIR") {
                let import_path = std::path::Path::new(&import_dir);
                match ws.import_from_directory(import_path).await {
                    Ok(count) if count > 0 => {
                        tracing::debug!("Imported {} workspace file(s) from {}", count, import_dir);
                    }
                    Ok(_) => {}
                    Err(e) => {
                        tracing::warn!(
                            "Failed to import workspace files from {}: {}",
                            import_dir,
                            e
                        );
                    }
                }
            }

            match ws.seed_if_empty().await {
                Ok(_) => {}
                Err(e) => {
                    tracing::warn!("Failed to seed workspace: {}", e);
                }
            }

            if embeddings.is_some() {
                let ws_bg = Arc::clone(ws);
                tokio::spawn(async move {
                    match ws_bg.backfill_embeddings().await {
                        Ok(count) if count > 0 => {
                            tracing::debug!("Backfilled embeddings for {} chunks", count);
                        }
                        Ok(_) => {}
                        Err(e) => {
                            tracing::warn!("Failed to backfill embeddings: {}", e);
                        }
                    }
                });
            }
        }

        // Skills system
        let (skill_registry, skill_catalog) = if self.config.skills.enabled {
            let mut registry = SkillRegistry::new(self.config.skills.local_dir.clone())
                .with_installed_dir(self.config.skills.installed_dir.clone());
            let loaded = registry.discover_all().await;
            if !loaded.is_empty() {
                tracing::debug!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
            }
            let registry = Arc::new(std::sync::RwLock::new(registry));
            let catalog = crate::skills::catalog::shared_catalog();
            tools.register_skill_tools(Arc::clone(&registry), Arc::clone(&catalog));
            (Some(registry), Some(catalog))
        } else {
            (None, None)
        };

        let context_manager = Arc::new(ContextManager::new(self.config.agent.max_parallel_jobs));
        let cost_guard = Arc::new(crate::agent::cost_guard::CostGuard::new(
            crate::agent::cost_guard::CostGuardConfig {
                max_cost_per_day_cents: self.config.agent.max_cost_per_day_cents,
                max_actions_per_hour: self.config.agent.max_actions_per_hour,
            },
        ));

        tracing::debug!(
            "Tool registry initialized with {} total tools",
            tools.count()
        );

        Ok(AppComponents {
            config: self.config,
            db: self.db,
            secrets_store: self.secrets_store,
            llm,
            cheap_llm,
            safety,
            tools,
            embeddings,
            workspace,
            extension_manager,
            mcp_session_manager,
            mcp_process_manager,
            wasm_tool_runtime,
            log_broadcaster: self.log_broadcaster,
            context_manager,
            hooks,
            agent_session_manager,
            skill_registry,
            skill_catalog,
            cost_guard,
            recording_handle,
            session: self.session,
            catalog_entries,
            dev_loaded_tool_names,
            builder,
        })
    }
}

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

    use async_trait::async_trait;
    use tokio::sync::mpsc;

    use crate::agent::SessionManager as AgentSessionManager;
    use crate::hooks::{
        Hook, HookContext, HookError, HookEvent, HookOutcome, HookPoint, HookRegistry,
    };

    struct SessionStartHook {
        tx: mpsc::UnboundedSender<(String, String)>,
    }

    #[async_trait]
    impl Hook for SessionStartHook {
        fn name(&self) -> &str {
            "session-start-test"
        }

        fn hook_points(&self) -> &[HookPoint] {
            &[HookPoint::OnSessionStart]
        }

        async fn execute(
            &self,
            event: &HookEvent,
            _ctx: &HookContext,
        ) -> Result<HookOutcome, HookError> {
            if let HookEvent::SessionStart {
                user_id,
                session_id,
            } = event
            {
                self.tx
                    .send((user_id.clone(), session_id.clone()))
                    .expect("test channel receiver should be alive");
            } else {
                panic!("SessionStartHook received an unexpected event: {event:?}");
            }
            Ok(HookOutcome::ok())
        }
    }

    #[tokio::test]
    async fn agent_session_manager_runs_session_start_hooks() {
        let hooks = Arc::new(HookRegistry::new());
        let (tx, mut rx) = mpsc::unbounded_channel();
        hooks.register(Arc::new(SessionStartHook { tx })).await;

        let manager = AgentSessionManager::new().with_hooks(Arc::clone(&hooks));
        manager.get_or_create_session("user-123").await;

        let (user_id, session_id) =
            tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
                .await
                .expect("session start hook should fire")
                .expect("session start payload should be present");

        assert_eq!(user_id, "user-123");
        assert!(!session_id.is_empty());
    }
}
