pub mod conventions;
pub(crate) mod edit;
pub(crate) mod edit_format;
pub mod edit_hooks;
pub mod edit_plan;
pub(crate) mod edit_tools;
pub mod explore;
pub mod format;
pub mod investigation;
pub mod prompts;
pub(crate) mod read_tools;
pub mod resources;
pub mod result_status;
pub(crate) mod search_format;
pub(crate) mod search_tools;
pub mod session;
pub mod smart_query;
pub mod tools;

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use parking_lot::{Mutex, RwLock};

use rmcp::RoleServer;
use rmcp::handler::server::router::prompt::PromptRouter;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::model::{
    GetPromptRequestParams, GetPromptResult, ListPromptsResult, ListResourceTemplatesResult,
    ListResourcesResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult,
    ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::RequestContext;
use rmcp::{ServerHandler, prompt_handler, tool_handler};

use crate::live_index::SharedIndex;
use crate::protocol::result_status::OutcomeClass;
use crate::sidecar::TokenStats;
use crate::watcher::WatcherInfo;

/// The MCP server struct.
///
/// Holds a `SharedIndex` and a `ToolRouter` generated by the `#[tool_router]` macro.
/// Holds a `PromptRouter` generated by the `#[prompt_router]` macro.
/// The `project_name` field is passed through to `format::repo_outline`.
/// The `watcher_info` field is read by the health tool to report live watcher state.
/// The `repo_root` field is used by `index_folder` to restart the watcher at a new root.
/// The `token_stats` field provides live hook token savings to the health tool (AD-4 simpler path).
#[derive(Clone)]
pub struct SymForgeServer {
    pub(crate) index: SharedIndex,
    pub(crate) tool_router: ToolRouter<Self>,
    pub(crate) prompt_router: PromptRouter<Self>,
    pub(crate) project_name: String,
    pub(crate) watcher_info: Arc<Mutex<WatcherInfo>>,
    /// MUST NOT be held across `.await`. Use `.lock().take()` and
    /// `.lock().replace(...)` around async work; `parking_lot::Mutex` is
    /// non-async and held-across-await would deadlock the runtime.
    /// Some only in local-stdio mode where this server owns its watcher.
    /// None in daemon-proxy mode and daemon-degraded mode.
    pub(crate) watcher_handle: Arc<Mutex<Option<crate::watcher::WatcherTaskHandle>>>,
    /// Root directory the watcher is currently watching. Stored in shared mutable
    /// state so local stdio tools can keep using the latest project root after
    /// `index_folder` rebinds the server to a new workspace.
    pub(crate) repo_root: Arc<RwLock<Option<PathBuf>>>,
    /// Shared token stats from the HTTP sidecar. Present when the sidecar is running.
    /// The health tool reads this to display token savings accumulated during the session.
    pub(crate) token_stats: Option<Arc<TokenStats>>,
    /// Optional daemon-backed proxy session. When present, tool calls are forwarded to the
    /// shared daemon-owned project runtime instead of the local in-process index.
    /// Wrapped in `tokio::sync::RwLock` so reconnection can swap in a fresh client.
    pub(crate) daemon_client: Option<Arc<tokio::sync::RwLock<crate::daemon::DaemonSessionClient>>>,
    /// Set to `true` after a failed reconnection attempt. While degraded,
    /// calls make one success probe but avoid repeated reconnect attempts.
    pub(crate) daemon_degraded: Arc<AtomicBool>,
    /// Session context tracking: records what the LLM has fetched this session.
    pub(crate) session_context: Arc<session::SessionContext>,
    /// Tracks edit-tool calls that omitted `working_directory` while the
    /// transitional worktree observability knob was on. Surfaced by the
    /// `health` tool as a rolling "last hour" signal.
    pub(crate) worktree_misuse: Arc<crate::worktree::WorktreeMisuseCounter>,
    /// Bounded analytics queue. Disabled by default until an explicit local
    /// analytics configuration installs an enabled recorder.
    pub(crate) analytics_recorder: Arc<RwLock<crate::analytics::AnalyticsRecorder>>,
}

fn default_analytics_db_path(repo_root: Option<&Path>) -> PathBuf {
    repo_root
        .map(|root| root.join(crate::paths::SYMFORGE_ANALYTICS_DB_PATH))
        .unwrap_or_else(|| PathBuf::from(crate::paths::SYMFORGE_ANALYTICS_DB_PATH))
}

fn disabled_analytics_recorder(repo_root: Option<&Path>) -> crate::analytics::AnalyticsRecorder {
    crate::analytics::AnalyticsRecorder::disabled(default_analytics_db_path(repo_root))
}

fn estimate_tokens(response_bytes: u64) -> u64 {
    response_bytes.saturating_add(3) / 4
}

impl SymForgeServer {
    pub(crate) fn tool_router() -> ToolRouter<Self> {
        Self::core_tool_router() + Self::edit_tool_router()
    }

    /// Create a new server with the given shared index, project name, and watcher state.
    ///
    /// `token_stats` is optional — when `Some`, the health tool will include a token savings
    /// section showing per-hook-type fire counts and estimated tokens saved this session.
    pub fn new(
        index: SharedIndex,
        project_name: String,
        watcher_info: Arc<Mutex<WatcherInfo>>,
        repo_root: Option<PathBuf>,
        token_stats: Option<Arc<TokenStats>>,
    ) -> Self {
        crate::worktree::register_if_feature_enabled();
        let analytics_recorder = disabled_analytics_recorder(repo_root.as_deref());
        Self {
            index,
            tool_router: Self::tool_router(),
            prompt_router: Self::prompt_router(),
            project_name,
            watcher_info,
            watcher_handle: Arc::new(Mutex::new(None)),
            repo_root: Arc::new(RwLock::new(repo_root)),
            token_stats,
            daemon_client: None,
            daemon_degraded: Arc::new(AtomicBool::new(false)),
            session_context: Arc::new(session::SessionContext::new()),
            worktree_misuse: Arc::new(crate::worktree::WorktreeMisuseCounter::new()),
            analytics_recorder: Arc::new(RwLock::new(analytics_recorder)),
        }
    }

    pub fn new_daemon_proxy(daemon_client: crate::daemon::DaemonSessionClient) -> Self {
        use crate::watcher::WatcherInfo;

        crate::worktree::register_if_feature_enabled();
        let project_name = daemon_client.project_name().to_string();
        let repo_root = daemon_client.project_root().map(|p| p.to_path_buf());
        let analytics_recorder = disabled_analytics_recorder(repo_root.as_deref());
        Self {
            index: crate::live_index::LiveIndex::empty(),
            tool_router: Self::tool_router(),
            prompt_router: Self::prompt_router(),
            project_name,
            watcher_info: Arc::new(Mutex::new(WatcherInfo::default())),
            watcher_handle: Arc::new(Mutex::new(None)),
            repo_root: Arc::new(RwLock::new(repo_root)),
            token_stats: None,
            daemon_client: Some(Arc::new(tokio::sync::RwLock::new(daemon_client))),
            daemon_degraded: Arc::new(AtomicBool::new(false)),
            session_context: Arc::new(session::SessionContext::new()),
            worktree_misuse: Arc::new(crate::worktree::WorktreeMisuseCounter::new()),
            analytics_recorder: Arc::new(RwLock::new(analytics_recorder)),
        }
    }

    /// Accessor for tests.
    pub fn index(&self) -> &SharedIndex {
        &self.index
    }

    /// Return the MCP tool definitions advertised by this server.
    ///
    /// This is a read-only view over the generated router metadata so integration
    /// tests and diagnostics can validate client compatibility without spinning up
    /// a full stdio transport.
    pub fn tool_definitions() -> Vec<Tool> {
        Self::tool_router().list_all()
    }

    pub(crate) fn capture_repo_root(&self) -> Option<PathBuf> {
        self.repo_root.read().clone()
    }

    /// Record a frecency bump for the given paths against the bound workspace.
    ///
    /// No-op when no repo root is bound (the feature has nothing to anchor
    /// the per-workspace store to). Forwards to
    /// [`crate::live_index::frecency::bump`], which itself resolves session,
    /// persistent, or disabled collection policy.
    pub(crate) fn bump_frecency(&self, paths: &[PathBuf]) {
        if let Some(root) = self.capture_repo_root() {
            crate::live_index::frecency::bump(&root, paths);
        }
    }

    pub(crate) fn effective_repo_root_for_git_tools(&self) -> Option<PathBuf> {
        self.capture_repo_root()
            .or_else(crate::discovery::find_project_root)
    }

    pub(crate) fn set_repo_root(&self, repo_root: Option<PathBuf>) {
        *self.repo_root.write() = repo_root.clone();
        if self.analytics_recorder.read().status().disabled {
            *self.analytics_recorder.write() = disabled_analytics_recorder(repo_root.as_deref());
        }
    }

    /// Bump the worktree-awareness misuse counter when an edit handler
    /// was called without `working_directory` while worktree routing is active.
    /// No-op when the caller supplied the parameter or policy disables routing.
    /// The counter surfaces in `health` output as a rolling "last hour" signal
    /// for callers that should already be passing worktree routing context.
    pub(crate) fn note_worktree_misuse_if_active(&self, working_directory: Option<&str>) {
        if working_directory.is_none()
            && crate::worktree::routing_policy_from_env()
                == crate::capability::WorktreeRoutingPolicy::ExplicitCallTime
        {
            self.worktree_misuse.record_missing_working_directory();
        }
    }

    /// Record token savings from an MCP tool response so the health counter accumulates.
    pub(crate) fn record_read_savings(&self, saved_tokens: u64) {
        if let Some(ref stats) = self.token_stats {
            stats
                .read_saved_tokens
                .fetch_add(saved_tokens, std::sync::atomic::Ordering::Relaxed);
            stats
                .read_fires
                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        }
    }

    /// Record token savings from any MCP tool that replaces raw file reads.
    pub(crate) fn record_tool_savings(&self, estimated_raw_tokens: u64, output_tokens: u64) {
        if let Some(ref stats) = self.token_stats {
            let saved = estimated_raw_tokens.saturating_sub(output_tokens);
            stats
                .read_saved_tokens
                .fetch_add(saved, std::sync::atomic::Ordering::Relaxed);
            stats
                .read_fires
                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        }
    }

    /// Record token savings from a named MCP tool, tracking per-tool breakdown.
    pub(crate) fn record_tool_savings_named(
        &self,
        tool_name: &str,
        estimated_raw_tokens: u64,
        output_tokens: u64,
    ) {
        if let Some(ref stats) = self.token_stats {
            let saved = estimated_raw_tokens.saturating_sub(output_tokens);
            stats
                .read_saved_tokens
                .fetch_add(saved, std::sync::atomic::Ordering::Relaxed);
            stats
                .read_fires
                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            stats.record_tool_tokens(tool_name, output_tokens, saved);
        }
    }

    pub(crate) fn record_tool_completion(
        &self,
        tool_name: &'static str,
        response_text: &str,
        duration: Duration,
        outcome_class: OutcomeClass,
    ) {
        let recorder = self.analytics_recorder.read().clone();
        let response_bytes = response_text.len() as u64;
        let observation = crate::analytics::AnalyticsObservation::new(
            tool_name,
            crate::analytics::AnalyticsSurface::Tool,
            recorder.configured_scope(),
            response_bytes,
            Some(estimate_tokens(response_bytes)),
            duration,
            !outcome_class.is_error(),
            outcome_class,
        );
        let _ = recorder.enqueue(observation);
    }

    #[cfg(test)]
    pub(crate) fn set_analytics_recorder_for_tests(
        &self,
        recorder: crate::analytics::AnalyticsRecorder,
    ) {
        *self.analytics_recorder.write() = recorder;
    }

    #[cfg(test)]
    pub(crate) fn analytics_queue_status_for_tests(
        &self,
    ) -> crate::analytics::AnalyticsQueueStatus {
        self.analytics_recorder.read().status()
    }

    /// Forward a tool call to the daemon. Returns:
    /// - `Some(result)` on success
    /// - `None` on connection failure (after one reconnect attempt), signalling
    ///   the caller to fall through to local execution
    pub(crate) async fn proxy_tool_call<T>(&self, tool_name: &str, params: &T) -> Option<String>
    where
        T: serde::Serialize,
    {
        let daemon_lock = self.daemon_client.as_ref()?;

        let was_degraded = self.daemon_degraded.load(Ordering::Relaxed);

        let value = match serde_json::to_value(params) {
            Ok(value) => value,
            Err(error) => return Some(format!("Daemon proxy serialization failed: {error}")),
        };

        // First attempt.
        // IMPORTANT: The read lock on daemon_lock is held for the duration of
        // the HTTP call. If this hangs, it blocks reconnect (which needs a
        // write lock). We wrap in a timeout so read locks release promptly
        // on failure — the reqwest client also has its own timeout as a
        // backstop, but this ensures lock release within a tighter window.
        {
            let client = daemon_lock.read().await;
            match tokio::time::timeout(
                std::time::Duration::from_secs(10),
                client.call_tool_value(tool_name, value.clone()),
            )
            .await
            {
                Ok(Ok(result)) => {
                    self.daemon_degraded.store(false, Ordering::Relaxed);
                    return Some(result);
                }
                Ok(Err(error)) => {
                    tracing::warn!(
                        tool = tool_name,
                        "daemon proxy call failed, attempting reconnect: {error}"
                    );
                }
                Err(_elapsed) => {
                    tracing::warn!(
                        tool = tool_name,
                        "daemon proxy call timed out after 10s, attempting reconnect"
                    );
                }
            }
        }

        if was_degraded {
            tracing::warn!(
                tool = tool_name,
                "daemon proxy probe failed while degraded, falling back to local execution"
            );
            self.ensure_local_index().await;
            return None;
        }

        // Reconnect attempt — take a write lock so only one caller does this.
        let reconnected = {
            let mut client = daemon_lock.write().await;
            match client.reconnect().await {
                Ok(new_client) => {
                    tracing::info!("daemon reconnected successfully");
                    *client = new_client;
                    true
                }
                Err(reconnect_error) => {
                    tracing::warn!(
                        "daemon reconnect failed, falling back to local execution: {reconnect_error}"
                    );
                    self.daemon_degraded.store(true, Ordering::Relaxed);
                    self.ensure_local_index().await;
                    false
                }
            }
        };

        if !reconnected {
            return None;
        }

        // Retry with the fresh client.
        {
            let client = daemon_lock.read().await;
            match tokio::time::timeout(
                std::time::Duration::from_secs(30),
                client.call_tool_value(tool_name, value),
            )
            .await
            {
                Ok(Ok(result)) => {
                    self.daemon_degraded.store(false, Ordering::Relaxed);
                    Some(result)
                }
                Ok(Err(error)) => {
                    tracing::warn!(
                        tool = tool_name,
                        "daemon proxy call failed after reconnect, falling back to local: {error}"
                    );
                    self.daemon_degraded.store(true, Ordering::Relaxed);
                    self.ensure_local_index().await;
                    None
                }
                Err(_elapsed) => {
                    tracing::warn!(
                        tool = tool_name,
                        "daemon proxy call timed out after reconnect, falling back to local"
                    );
                    self.daemon_degraded.store(true, Ordering::Relaxed);
                    self.ensure_local_index().await;
                    None
                }
            }
        }
    }

    pub(crate) async fn proxy_tool_call_without_params(&self, tool_name: &str) -> Option<String> {
        self.proxy_tool_call(tool_name, &serde_json::json!({}))
            .await
    }

    /// Lazily initialize the local index when the daemon is unreachable.
    /// Uses `SharedIndexHandle::reload` to populate the empty in-process index
    /// from disk, enabling graceful degradation to local-only mode.
    ///
    /// Root-mismatch invalidation: the early return fires only when the index is
    /// non-empty AND was built from the SAME root we are now targeting. If the
    /// target `repo_root` changed (a project switch via any `set_repo_root`
    /// caller) the recorded root no longer matches, so we fall through and
    /// reload from the current root instead of serving the previous project.
    /// This closes the stale-project class structurally — no caller has to
    /// remember to call `reset_to_empty` for correctness (that path still works
    /// and remains as defense in depth). Both roots are normalized via the same
    /// [`normalize_root`](crate::live_index::store::normalize_root) helper so the
    /// steady state (same project, repeated calls) never reloads on a cosmetic
    /// `\\?\` / trailing-slash / separator / case difference.
    async fn ensure_local_index(&self) {
        {
            let mut watcher = self.watcher_info.lock();
            *watcher = WatcherInfo::detached_local_fallback();
        }

        let repo_root = self.capture_repo_root();

        // Early return only when the index is non-empty AND its recorded root
        // matches the current target root (normalized compare). A non-empty
        // index built from a different root is stale: fall through to reload.
        let published = self.index.published_state();
        if published.file_count > 0 {
            let target_root = repo_root
                .as_deref()
                .map(crate::live_index::store::normalize_root);
            match (&published.indexed_root, &target_root) {
                // Same project: serve the loaded index, no reload.
                (Some(indexed), Some(target)) if indexed == target => return,
                // No target root to compare against (cannot do better than the
                // already-loaded index): keep serving it rather than dropping to
                // an empty index we cannot repopulate.
                (_, None) => return,
                // Root mismatch (or an index with no recorded root): stale —
                // fall through and reload from the current target root.
                _ => {}
            }
        }

        if let Some(root) = repo_root {
            tracing::info!(
                root = %root.display(),
                "daemon unreachable — loading local index as fallback"
            );
            match self.index.reload(&root) {
                Ok(()) => {
                    let published = self.index.published_state();
                    tracing::info!(
                        files = published.file_count,
                        symbols = published.symbol_count,
                        "local fallback index loaded"
                    );

                    // Spawn git temporal computation so co-change queries work
                    // after daemon degradation (mirrors index_folder behaviour).
                    let expected_gen = self.index.current_project_generation();
                    crate::live_index::git_temporal::spawn_git_temporal_computation(
                        Arc::clone(&self.index),
                        root,
                        expected_gen,
                    );
                }
                Err(error) => {
                    tracing::error!("failed to load local fallback index: {error}");
                }
            }
        } else {
            tracing::warn!("daemon unreachable and no repo root available for local fallback");
        }
    }

    /// Test-only dispatcher that routes a tool call by name and JSON payload.
    ///
    /// Mirrors the tool-name match in `daemon::execute_tool_call`, but takes
    /// `&self` so parity tests can exercise handlers directly without a live
    /// daemon or MCP transport. Only the tools needed by the `tests/` suites
    /// are wired up here.
    #[doc(hidden)]
    pub async fn dispatch_tool_for_tests(
        &self,
        tool_name: &str,
        params: serde_json::Value,
    ) -> String {
        use rmcp::handler::server::wrapper::Parameters;
        fn decode<T: serde::de::DeserializeOwned>(params: serde_json::Value) -> Result<T, String> {
            serde_json::from_value(params).map_err(|e| format!("invalid tool parameters: {e}"))
        }
        macro_rules! call {
            ($method:ident, $input:ty) => {{
                match decode::<$input>(params) {
                    Ok(input) => self.$method(Parameters(input)).await,
                    Err(e) => e,
                }
            }};
        }
        match tool_name {
            "replace_symbol_body" => call!(replace_symbol_body, edit::ReplaceSymbolBodyInput),
            "insert_symbol" => call!(insert_symbol, edit::InsertSymbolInput),
            "delete_symbol" => call!(delete_symbol, edit::DeleteSymbolInput),
            "edit_within_symbol" => call!(edit_within_symbol, edit::EditWithinSymbolInput),
            "batch_edit" => call!(batch_edit, edit::BatchEditInput),
            "batch_rename" => call!(batch_rename, edit::BatchRenameInput),
            "batch_insert" => call!(batch_insert, edit::BatchInsertInput),
            "search_files" => call!(search_files, tools::SearchFilesInput),
            "search_text" => call!(search_text, tools::SearchTextInput),
            "search_symbols" => call!(search_symbols, tools::SearchSymbolsInput),
            "get_file_context" => call!(get_file_context, tools::GetFileContextInput),
            "get_file_content" => call!(get_file_content, tools::GetFileContentInput),
            "get_symbol" => call!(get_symbol, tools::GetSymbolInput),
            "get_symbol_context" => call!(get_symbol_context, tools::GetSymbolContextInput),
            "health" => self.health().await,
            "health_compact" => self.health_compact().await,
            "conventions" => self.conventions().await,
            other => format!("dispatch_tool_for_tests: unknown tool '{other}'"),
        }
    }

    /// Test-only dispatcher that preserves public `CallToolResult` status metadata.
    ///
    /// This intentionally wires only handlers that already emit the SFB09 result-status
    /// contract. It lets conformance tests replay canonical JSON requests without
    /// starting an MCP transport or accepting string-only fake success paths.
    #[doc(hidden)]
    pub async fn dispatch_tool_result_for_tests(
        &self,
        tool_name: &str,
        params: serde_json::Value,
    ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
        use rmcp::handler::server::wrapper::Parameters;

        fn invalid_request_result(
            tool_name: &str,
            message: impl Into<String>,
        ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
            let text = format!(
                "Invalid request for tool `{tool_name}`: {}\nRecovery: compare the request JSON with the advertised input schema from tool_definitions().",
                message.into()
            );
            Ok(
                result_status::ResultStatus::new(result_status::OutcomeClass::InvalidRequest)
                    .into_call_tool_result(text),
            )
        }

        fn decode<T: serde::de::DeserializeOwned>(params: serde_json::Value) -> Result<T, String> {
            serde_json::from_value(params).map_err(|e| format!("invalid tool parameters: {e}"))
        }

        macro_rules! call_statused {
            ($method:ident, $input:ty) => {{
                match decode::<$input>(params) {
                    Ok(input) => self.$method(Parameters(input)).await,
                    Err(error) => invalid_request_result(tool_name, error),
                }
            }};
        }

        match tool_name {
            "replace_symbol_body" => {
                call_statused!(replace_symbol_body_tool, edit::ReplaceSymbolBodyInput)
            }
            "batch_edit" => call_statused!(batch_edit_tool, edit::BatchEditInput),
            "batch_insert" => call_statused!(batch_insert_tool, edit::BatchInsertInput),
            "search_files" => call_statused!(search_files_tool, tools::SearchFilesInput),
            "search_text" => call_statused!(search_text_tool, tools::SearchTextInput),
            "search_symbols" => call_statused!(search_symbols_tool, tools::SearchSymbolsInput),
            "get_file_content" => {
                call_statused!(get_file_content_tool, tools::GetFileContentInput)
            }
            "get_symbol" => call_statused!(get_symbol_tool, tools::GetSymbolInput),
            "get_symbol_context" => {
                call_statused!(get_symbol_context_tool, tools::GetSymbolContextInput)
            }
            "find_references" => call_statused!(find_references_tool, tools::FindReferencesInput),
            other => {
                let text = format!(
                    "Unsupported tool `{other}` in public conformance harness.\nRecovery: add a statused dispatcher branch before adding the case, or remove the case from the public corpus."
                );
                Ok(
                    result_status::ResultStatus::new(result_status::OutcomeClass::InvalidRequest)
                        .into_call_tool_result(text),
                )
            }
        }
    }
}

/// Wire `SymForgeServer` as an MCP `ServerHandler`.
///
/// The `#[tool_handler]` macro delegates tool dispatch to `self.tool_router`
/// and supplies the `call_tool` / `list_tools` implementations automatically.
#[tool_handler(router = self.tool_router)]
#[prompt_handler(router = self.prompt_router)]
impl ServerHandler for SymForgeServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo::new(
            ServerCapabilities::builder()
                .enable_tools()
                .enable_prompts()
                .enable_resources()
                .build(),
        )
    }

    fn list_resources(
        &self,
        _request: Option<PaginatedRequestParams>,
        _context: RequestContext<RoleServer>,
    ) -> impl std::future::Future<Output = Result<ListResourcesResult, rmcp::ErrorData>> + Send + '_
    {
        std::future::ready(Ok(ListResourcesResult {
            resources: self.resource_definitions(),
            ..Default::default()
        }))
    }

    fn list_resource_templates(
        &self,
        _request: Option<PaginatedRequestParams>,
        _context: RequestContext<RoleServer>,
    ) -> impl std::future::Future<Output = Result<ListResourceTemplatesResult, rmcp::ErrorData>>
    + Send
    + '_ {
        std::future::ready(Ok(ListResourceTemplatesResult {
            resource_templates: self.resource_template_definitions(),
            ..Default::default()
        }))
    }

    fn read_resource(
        &self,
        request: ReadResourceRequestParams,
        _context: RequestContext<RoleServer>,
    ) -> impl std::future::Future<Output = Result<ReadResourceResult, rmcp::ErrorData>> + Send + '_
    {
        let uri = request.uri;
        async move { self.read_resource_uri(&uri).await }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::Router;
    use axum::extract::State;
    use axum::routing::post;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use tokio::net::TcpListener;

    #[derive(Clone)]
    struct FakeToolState {
        calls: Arc<AtomicUsize>,
        body: Arc<String>,
    }

    async fn fake_tool_handler(State(state): State<FakeToolState>) -> String {
        state.calls.fetch_add(1, Ordering::Relaxed);
        state.body.as_ref().clone()
    }

    async fn spawn_fake_tool_server(
        body: &str,
    ) -> (String, tokio::sync::oneshot::Sender<()>, Arc<AtomicUsize>) {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind fake daemon tool server");
        let base_url = format!("http://{}", listener.local_addr().expect("listener addr"));
        let calls = Arc::new(AtomicUsize::new(0));
        let state = FakeToolState {
            calls: Arc::clone(&calls),
            body: Arc::new(body.to_string()),
        };
        let app = Router::new()
            .route(
                "/v1/sessions/{session_id}/tools/{tool_name}",
                post(fake_tool_handler),
            )
            .with_state(state);
        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
        tokio::spawn(async move {
            let shutdown = async move {
                let _ = shutdown_rx.await;
            };
            let _ = axum::serve(listener, app)
                .with_graceful_shutdown(shutdown)
                .await;
        });
        (base_url, shutdown_tx, calls)
    }

    #[tokio::test]
    async fn daemon_degraded_clears_on_next_success() {
        let (base_url, shutdown, calls) = spawn_fake_tool_server("daemon-ok").await;
        let daemon_client = crate::daemon::DaemonSessionClient::new_for_test(
            base_url,
            "project-id".to_string(),
            "session-id".to_string(),
            "project-name".to_string(),
        );
        let server = SymForgeServer::new_daemon_proxy(daemon_client);
        server.daemon_degraded.store(true, Ordering::Relaxed);

        let result = server
            .proxy_tool_call("health", &serde_json::json!({}))
            .await;
        let _ = shutdown.send(());

        assert_eq!(result.as_deref(), Some("daemon-ok"));
        assert_eq!(
            calls.load(Ordering::Relaxed),
            1,
            "degraded proxy should still probe the daemon once"
        );
        assert!(
            !server.daemon_degraded.load(Ordering::Relaxed),
            "successful proxy call should clear daemon_degraded"
        );
    }

    // ── ensure_local_index: root-mismatch invalidation (M2) ──────────────────

    /// Build a local-mode server (no daemon proxy) starting from an empty index,
    /// targeting `repo_root`. Mirrors the production `SymForgeServer::new` wiring
    /// used by the local stdio path.
    fn make_local_server(repo_root: Option<std::path::PathBuf>) -> SymForgeServer {
        let index = crate::live_index::LiveIndex::empty();
        let watcher_info = Arc::new(Mutex::new(WatcherInfo::default()));
        SymForgeServer::new(
            index,
            "test_project".to_string(),
            watcher_info,
            repo_root,
            None,
        )
    }

    /// Write a single `.rs` file under `dir` so a real local reload produces a
    /// non-empty index whose contents identify the project.
    fn seed_project(dir: &std::path::Path, file_name: &str, body: &str) {
        std::fs::write(dir.join(file_name), body).expect("seed project source file");
    }

    /// Core M2 regression: a changed `repo_root` (WITHOUT `reset_to_empty`) must
    /// force `ensure_local_index` to reload the new root instead of serving the
    /// previous project. Fails without the root-mismatch guard.
    #[tokio::test]
    async fn ensure_local_index_reloads_on_root_mismatch_without_reset() {
        let dir_a = tempfile::TempDir::new().expect("temp dir A");
        let dir_b = tempfile::TempDir::new().expect("temp dir B");
        seed_project(dir_a.path(), "alpha.rs", "fn alpha_only() {}\n");
        seed_project(dir_b.path(), "beta.rs", "fn beta_only() {}\n");

        // 1. Load project A.
        let server = make_local_server(Some(dir_a.path().to_path_buf()));
        server.ensure_local_index().await;

        let published_a = server.index.published_state();
        assert!(
            published_a.file_count > 0,
            "project A should have loaded a non-empty index"
        );
        assert_eq!(
            published_a.indexed_root.as_deref(),
            Some(crate::live_index::store::normalize_root(dir_a.path()).as_path()),
            "loaded index must record project A's normalized root"
        );
        assert!(
            server.index.read().get_file("alpha.rs").is_some(),
            "project A's file should be present after the first load"
        );

        // 2. Switch the target root to B WITHOUT calling reset_to_empty.
        server.set_repo_root(Some(dir_b.path().to_path_buf()));

        // 3. The next ensure_local_index must detect the root mismatch and reload B.
        server.ensure_local_index().await;

        let published_b = server.index.published_state();
        assert_eq!(
            published_b.indexed_root.as_deref(),
            Some(crate::live_index::store::normalize_root(dir_b.path()).as_path()),
            "after a root switch, ensure_local_index must serve project B's root"
        );
        assert!(
            server.index.read().get_file("beta.rs").is_some(),
            "project B's file must be present after the root-mismatch reload"
        );
        assert!(
            server.index.read().get_file("alpha.rs").is_none(),
            "project A's stale file must be gone after switching to project B"
        );
    }

    /// Steady-state guard: repeated `ensure_local_index` calls against the SAME
    /// root must NOT rebuild the index. A spurious reload bumps the project
    /// generation, so an unchanged generation (and unchanged recorded root)
    /// proves no reload occurred — verifying normalization makes the common path
    /// cheap rather than reloading on every call.
    #[tokio::test]
    async fn ensure_local_index_does_not_reload_on_repeated_same_root_calls() {
        let dir_a = tempfile::TempDir::new().expect("temp dir A");
        seed_project(dir_a.path(), "alpha.rs", "fn alpha_only() {}\n");

        let server = make_local_server(Some(dir_a.path().to_path_buf()));

        // First call performs the one and only load.
        server.ensure_local_index().await;
        let gen_after_first = server.index.current_project_generation();
        let root_after_first = server.index.published_state().indexed_root.clone();
        assert!(
            server.index.published_state().file_count > 0,
            "first ensure_local_index call should have loaded project A"
        );

        // Repeated calls with the same root must be no-ops (no reload).
        for _ in 0..3 {
            server.ensure_local_index().await;
        }

        assert_eq!(
            server.index.current_project_generation(),
            gen_after_first,
            "repeated same-root ensure_local_index calls must not reload (project generation must not change)"
        );
        assert_eq!(
            server.index.published_state().indexed_root,
            root_after_first,
            "repeated same-root calls must leave the recorded root unchanged"
        );
    }
}
