/// LSP server trait implementation.
///
/// This module contains the `impl LanguageServer for Backend` block,
/// which handles all LSP protocol messages (initialize, didOpen, didChange,
/// didClose, completion, diagnostic, etc.).
///
/// **Diagnostic delivery.** Two models are supported, selected automatically
/// based on the client's capabilities:
///
/// - **Pull model** (preferred) — when the client advertises
///   `textDocument.diagnostic` support, the server registers a
///   `diagnostic_provider` capability.  The editor requests diagnostics
///   via `textDocument/diagnostic` for visible files and
///   `workspace/diagnostic` for all open files.  Cross-file invalidation
///   (e.g. a class signature change) sends `workspace/diagnostic/refresh`
///   so the editor re-pulls only the files it cares about.
///
/// - **Push model** (fallback) — for clients without pull support, the
///   server pushes diagnostics via `textDocument/publishDiagnostics`
///   from a debounced background worker.  Each `did_change` bumps a
///   version counter; the worker waits for a quiet period before
///   publishing.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;

use tower_lsp::LanguageServer;
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::request::{
    GotoImplementationParams, GotoImplementationResponse, GotoTypeDefinitionParams,
    GotoTypeDefinitionResponse,
};
use tower_lsp::lsp_types::*;

use crate::Backend;
use crate::classmap_scanner::{self, WorkspaceScanResult};
use crate::composer;
use crate::config::IndexingStrategy;
use crate::formatting;
use crate::phar;

#[tower_lsp::async_trait]
impl LanguageServer for Backend {
    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
        // Extract and store the workspace root path
        let workspace_root = params
            .root_uri
            .as_ref()
            .and_then(|uri| uri.to_file_path().ok());

        if let Some(root) = workspace_root {
            *self.workspace_root.write() = Some(root);
        }

        // Store the client name for quirks-mode adjustments.
        if let Some(info) = &params.client_info {
            *self.client_name.lock() = info.name.clone();
        }

        // Detect whether the client supports pull diagnostics.
        let client_supports_pull = params
            .capabilities
            .text_document
            .as_ref()
            .and_then(|td| td.diagnostic.as_ref())
            .is_some();
        self.supports_pull_diagnostics
            .store(client_supports_pull, Ordering::Release);

        // Detect whether the client supports file rename operations in
        // workspace edits.  Used by the rename handler to include a
        // `RenameFile` operation when a class rename matches PSR-4 naming.
        let client_supports_file_rename = params
            .capabilities
            .workspace
            .as_ref()
            .and_then(|ws| ws.workspace_edit.as_ref())
            .and_then(|we| we.resource_operations.as_ref())
            .is_some_and(|ops| ops.contains(&ResourceOperationKind::Rename));
        self.supports_file_rename
            .store(client_supports_file_rename, Ordering::Release);

        // Detect whether the client supports server-initiated work-done
        // progress (window/workDoneProgress/create).  Per the LSP spec,
        // we must not send that request unless the client opts in.
        let client_supports_work_done_progress = params
            .capabilities
            .window
            .as_ref()
            .and_then(|w| w.work_done_progress)
            .unwrap_or(false);
        self.supports_work_done_progress
            .store(client_supports_work_done_progress, Ordering::Release);

        // Detect whether the client supports dynamic registration for
        // type hierarchy.
        let client_supports_type_hierarchy_dynamic_registration = params
            .capabilities
            .text_document
            .as_ref()
            .and_then(|td| td.type_hierarchy.as_ref())
            .and_then(|th| th.dynamic_registration)
            .unwrap_or(false);
        self.supports_type_hierarchy_dynamic_registration.store(
            client_supports_type_hierarchy_dynamic_registration,
            Ordering::Release,
        );

        Ok(InitializeResult {
            offset_encoding: None,
            capabilities: ServerCapabilities {
                signature_help_provider: Some(SignatureHelpOptions {
                    trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
                    retrigger_characters: Some(vec![",".to_string(), ")".to_string()]),
                    work_done_progress_options: WorkDoneProgressOptions {
                        work_done_progress: None,
                    },
                }),
                completion_provider: Some(CompletionOptions {
                    resolve_provider: Some(true),
                    trigger_characters: Some(vec![
                        "$".to_string(),
                        ">".to_string(),
                        ":".to_string(),
                        "@".to_string(),
                        "'".to_string(),
                        "\"".to_string(),
                        "[".to_string(),
                        " ".to_string(),
                        "\\".to_string(),
                        "/".to_string(),
                        "*".to_string(),
                    ]),
                    all_commit_characters: None,
                    work_done_progress_options: WorkDoneProgressOptions {
                        work_done_progress: None,
                    },
                    completion_item: None,
                }),
                inlay_hint_provider: Some(OneOf::Left(true)),
                text_document_sync: Some(TextDocumentSyncCapability::Kind(
                    TextDocumentSyncKind::INCREMENTAL,
                )),
                hover_provider: Some(HoverProviderCapability::Simple(true)),
                definition_provider: Some(OneOf::Left(true)),
                type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
                implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
                references_provider: Some(OneOf::Left(true)),
                document_highlight_provider: Some(OneOf::Left(true)),
                linked_editing_range_provider: Some(LinkedEditingRangeServerCapabilities::Simple(
                    true,
                )),
                code_action_provider: Some(CodeActionProviderCapability::Options(
                    CodeActionOptions {
                        code_action_kinds: Some(vec![
                            CodeActionKind::QUICKFIX,
                            CodeActionKind::REFACTOR_EXTRACT,
                            CodeActionKind::REFACTOR_INLINE,
                            CodeActionKind::new("source.organizeImports"),
                        ]),
                        work_done_progress_options: WorkDoneProgressOptions {
                            work_done_progress: None,
                        },
                        resolve_provider: Some(true),
                    },
                )),
                rename_provider: Some(OneOf::Right(RenameOptions {
                    prepare_provider: Some(true),
                    work_done_progress_options: WorkDoneProgressOptions {
                        work_done_progress: None,
                    },
                })),
                document_symbol_provider: Some(OneOf::Left(true)),
                workspace_symbol_provider: Some(OneOf::Left(true)),
                folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
                code_lens_provider: Some(CodeLensOptions {
                    resolve_provider: Some(false),
                }),
                selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
                document_formatting_provider: Some(OneOf::Left(true)),
                document_on_type_formatting_provider: Some(DocumentOnTypeFormattingOptions {
                    first_trigger_character: "\n".to_string(),
                    more_trigger_character: None,
                }),
                document_link_provider: Some(DocumentLinkOptions {
                    resolve_provider: Some(false),
                    work_done_progress_options: WorkDoneProgressOptions {
                        work_done_progress: None,
                    },
                }),
                semantic_tokens_provider: Some(
                    SemanticTokensServerCapabilities::SemanticTokensOptions(
                        SemanticTokensOptions {
                            work_done_progress_options: WorkDoneProgressOptions {
                                work_done_progress: None,
                            },
                            legend: crate::semantic_tokens::legend(),
                            range: Some(false),
                            full: Some(SemanticTokensFullOptions::Bool(true)),
                        },
                    ),
                ),
                diagnostic_provider: if client_supports_pull {
                    Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
                        identifier: Some("phpantom".to_string()),
                        inter_file_dependencies: true,
                        workspace_diagnostics: true,
                        work_done_progress_options: WorkDoneProgressOptions {
                            work_done_progress: None,
                        },
                    }))
                } else {
                    None
                },
                ..ServerCapabilities::default()
            },
            server_info: Some(ServerInfo {
                name: self.name.clone(),
                version: Some(self.version.clone()),
            }),
        })
    }

    async fn initialized(&self, _: InitializedParams) {
        // Parse composer.json for PSR-4 mappings if we have a workspace root
        let workspace_root = self.workspace_root.read().clone();

        if let Some(root) = workspace_root {
            // ── Load project configuration ──────────────────────────────
            // Read `.phpantom.toml` before anything else so that settings
            // (e.g. PHP version override, diagnostic toggles) are active
            // from the very first file load.
            match crate::config::load_config(&root) {
                Ok(cfg) => {
                    *self.config.lock() = cfg;
                }
                Err(e) => {
                    self.log(
                        MessageType::WARNING,
                        format!("Failed to load .phpantom.toml: {}", e),
                    )
                    .await;
                }
            }

            // Parse composer.json once up front.  The result is used for
            // PHP version detection and passed into init_single_project
            // so the file is never re-read during startup.
            let composer_package = composer::read_composer_package(&root);

            // Detect the target PHP version.  The config file override
            // takes precedence; otherwise fall back to composer.json.
            let php_version = self
                .config()
                .php
                .version
                .as_deref()
                .and_then(crate::types::PhpVersion::from_composer_constraint)
                .unwrap_or_else(|| {
                    composer_package
                        .as_ref()
                        .and_then(composer::detect_php_version_from_package)
                        .unwrap_or_default()
                });
            self.set_php_version(php_version);

            let has_composer_json = composer_package.is_some();

            // ── Create a progress token for indexing feedback ────────
            let progress_token = self.progress_create("phpantom/indexing").await;
            if let Some(ref tok) = progress_token {
                self.progress_begin(tok, "PHPantom: Indexing", Some("Starting".to_string()))
                    .await;
            }

            if has_composer_json {
                // ── Single-project path (root composer.json exists) ──────
                self.init_single_project(
                    &root,
                    php_version,
                    composer_package,
                    progress_token.as_ref(),
                )
                .await;
            } else {
                // ── Monorepo / non-Composer path ────────────────────────
                let subprojects = composer::discover_subproject_roots(&root);

                if !subprojects.is_empty() {
                    self.init_monorepo(&root, &subprojects, php_version, progress_token.as_ref())
                        .await;
                } else {
                    // No subprojects found — pure non-Composer workspace.
                    self.init_no_composer(&root, php_version, progress_token.as_ref())
                        .await;
                }
            }

            if let Some(ref tok) = progress_token {
                let classmap_count = self.fqn_uri_index.read().len();
                self.progress_end(tok, Some(format!("Indexed {} classes", classmap_count)))
                    .await;
            }
        } else {
            self.log(MessageType::INFO, "PHPantom initialized!".to_string())
                .await;
        }

        // Spawn the background diagnostic worker. We build a shallow
        // clone of `self` that shares every `Arc`-wrapped field (maps,
        // caches, the diagnostic notify/pending slot) so the worker
        // sees all mutations the real Backend makes.  Non-Arc fields
        // (php_version, vendor_uri_prefixes, vendor_dir_paths) are
        // snapshotted — they are only written during init (above) and
        // never change afterwards.
        let worker_backend = self.clone_for_diagnostic_worker();
        tokio::spawn(async move {
            worker_backend.diagnostic_worker().await;
        });

        // Spawn the PHPStan worker as a separate background task.
        // PHPStan is extremely slow and resource-intensive, so it runs
        // in its own task with its own debounce timer and pending-URI
        // slot.  At most one PHPStan process runs at a time.  Native
        // diagnostics (fast + slow phases) are never blocked.
        let phpstan_backend = self.clone_for_diagnostic_worker();
        tokio::spawn(async move {
            phpstan_backend.phpstan_worker().await;
        });

        // Spawn the PHPCS worker as a separate background task.
        // Same pattern as the PHPStan worker: dedicated task, own
        // debounce timer, single pending-URI slot.
        let phpcs_backend = self.clone_for_diagnostic_worker();
        tokio::spawn(async move {
            phpcs_backend.phpcs_worker().await;
        });

        // Spawn the Mago lint worker.  Same pattern as PHPCS: dedicated
        // task, own debounce timer, single pending-URI slot.  Mago lint
        // is fast (AST-level rules) so it uses the same debounce as PHPCS.
        let mago_lint_backend = self.clone_for_diagnostic_worker();
        tokio::spawn(async move {
            mago_lint_backend.mago_lint_worker().await;
        });

        // Spawn the Mago analyze worker.  Mago analyze is slower
        // (type-aware) so it follows the PHPStan pattern with a longer
        // debounce.
        let mago_analyze_backend = self.clone_for_diagnostic_worker();
        tokio::spawn(async move {
            mago_analyze_backend.mago_analyze_worker().await;
        });

        // ── Dynamic capability registration ─────────────────────────
        // lsp-types 0.94 does not expose a `type_hierarchy_provider`
        // field on `ServerCapabilities`, so we register the capability
        // dynamically via `client/registerCapability` instead.
        if self
            .supports_type_hierarchy_dynamic_registration
            .load(Ordering::Acquire)
            && let Some(client) = &self.client
        {
            let _ = client
                .register_capability(vec![Registration {
                    id: "type-hierarchy".to_string(),
                    method: "textDocument/prepareTypeHierarchy".to_string(),
                    register_options: None,
                }])
                .await;
        }

        // Clear the negative class-resolution cache.  During startup,
        // `did_open` may have triggered `update_ast` → `find_or_load_class`
        // before the fqn_uri_index was fully populated, caching
        // "not found" for classes that are now resolvable.  Without this
        // clear, those stale entries cause false-positive "Class not found"
        // diagnostics even though hover and go-to-definition (which run
        // later) resolve the same symbols correctly.  (B22)
        self.class_not_found_cache.write().clear();

        // Mark initialization as complete so that diagnostic workers
        // and pull handlers know the project is fully indexed.
        self.init_complete
            .store(true, std::sync::atomic::Ordering::Release);

        // Files opened during startup (before indexing finished) were
        // not diagnosed because `schedule_diagnostics` skips work when
        // `init_complete` is false.  Now that the index is ready,
        // diagnose every open file so the user sees results without
        // having to edit.
        //
        // We compute diagnostics eagerly here (via
        // `publish_diagnostics_for_file`) so the editor sees fast
        // diagnostics immediately.  In pull mode, slow diagnostics
        // are cached but only pushed as fast-only; we send a
        // `workspace/diagnostic/refresh` afterwards so the editor
        // re-pulls and gets the full set (fast + slow).
        {
            let file_snapshots: Vec<(String, Arc<String>)> = self
                .open_files
                .read()
                .iter()
                .map(|(uri, content)| (uri.clone(), Arc::clone(content)))
                .collect();
            for (uri, content) in &file_snapshots {
                self.schedule_diagnostics(uri.clone());
                self.publish_diagnostics_for_file(uri, content).await;
            }
        }

        // In pull mode the eager publish above only pushed fast
        // diagnostics.  The full set (including slow diagnostics) is
        // now cached in `diag_last_full`.  Send a refresh so the
        // editor re-pulls and receives the complete diagnostics.
        if self.supports_pull_diagnostics.load(Ordering::Acquire)
            && let Some(ref client) = self.client
        {
            let _ = client.workspace_diagnostic_refresh().await;
        }
    }

    async fn shutdown(&self) -> Result<()> {
        // Signal background workers (diagnostic, PHPStan, PHPCS) to
        // stop.  The PHPStan/PHPCS poll loops also check this flag,
        // so running child processes are killed within 50ms instead
        // of waiting up to 60 seconds.
        self.shutdown_flag.store(true, Ordering::Release);
        // Wake all workers so they see the flag immediately instead
        // of sleeping until the next edit arrives.
        self.diag_notify.notify_one();
        self.phpstan_notify.notify_one();
        self.phpcs_notify.notify_one();
        self.mago_lint_notify.notify_one();
        self.mago_analyze_notify.notify_one();
        Ok(())
    }

    async fn did_open(&self, params: DidOpenTextDocumentParams) {
        let doc = params.text_document;
        let uri = doc.uri.to_string();
        let text = Arc::new(doc.text);

        // Track files opened with languageId "blade" so they get
        // Blade preprocessing even without a .blade.php extension.
        if doc.language_id == "blade" && !crate::blade::is_blade_file(&uri) {
            self.blade_uris.write().insert(uri.clone());
        }

        // Store file content
        self.open_files
            .write()
            .insert(uri.clone(), Arc::clone(&text));

        // Parse and update AST map, use map, and namespace map
        self.update_ast(&uri, &text);

        // Schedule diagnostics asynchronously so that the first-open
        // response is not blocked by lazy stub parsing (which can take
        // tens of seconds when many class references trigger cache-miss
        // parses).  This matches the did_change path.
        self.schedule_diagnostics(uri.clone());

        self.log(MessageType::INFO, format!("Opened file: {}", uri))
            .await;
    }

    async fn did_change(&self, params: DidChangeTextDocumentParams) {
        let uri = params.text_document.uri.to_string();

        if params.content_changes.is_empty() {
            return;
        }

        // Apply incremental edits to the current content.
        // Each change event either has a range (incremental) or replaces
        // the entire document (range is None).
        let text = {
            let open_files = self.open_files.read();
            let mut current = open_files
                .get(&uri)
                .map(|s| s.to_string())
                .unwrap_or_default();
            drop(open_files);

            for change in &params.content_changes {
                if let Some(range) = change.range {
                    let start = crate::util::position_to_byte_offset(&current, range.start);
                    let end = crate::util::position_to_byte_offset(&current, range.end);
                    current.replace_range(start..end, &change.text);
                } else {
                    // Full content replacement (fallback)
                    current = change.text.clone();
                }
            }
            Arc::new(current)
        };

        // Update stored content
        self.open_files
            .write()
            .insert(uri.clone(), Arc::clone(&text));

        // Re-parse and update AST map, use map, and namespace map
        let signature_changed = self.update_ast(&uri, &text);

        // Schedule diagnostics in a background task with debouncing.
        // This returns immediately so that completion, hover, and
        // signature help are never blocked by diagnostic computation.
        self.schedule_diagnostics(uri.clone());

        // When a class signature changed (method/property added,
        // removed, or modified; class renamed; parent changed; etc.)
        // other open files may have stale diagnostics that reference
        // the affected classes.  Queue them all for a re-check.
        if signature_changed {
            self.schedule_diagnostics_for_open_files(&uri);
        }
    }

    async fn did_close(&self, params: DidCloseTextDocumentParams) {
        let uri = params.text_document.uri.to_string();

        self.open_files.write().remove(&uri);

        // Clean up Blade preprocessor state for the closed file.
        if self.is_blade_file(&uri) {
            self.blade_virtual_content.write().remove(&uri);
            self.blade_source_maps.write().remove(&uri);
            self.blade_uris.write().remove(&uri);
        }

        self.clear_file_maps(&uri);

        // Clear diagnostics so stale warnings don't linger after the file is closed
        self.clear_diagnostics_for_file(&uri).await;

        self.log(MessageType::INFO, format!("Closed file: {}", uri))
            .await;
    }

    async fn goto_definition(
        &self,
        params: GotoDefinitionParams,
    ) -> Result<Option<GotoDefinitionResponse>> {
        let uri = params
            .text_document_position_params
            .text_document
            .uri
            .to_string();
        let position = params.text_document_position_params.position;

        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        tokio::task::spawn_blocking(move || {
            backend.handle_with_position("goto_definition", &uri_clone, position, |content, pos| {
                let locs = backend.resolve_definition(&uri_clone, content, pos);
                if locs.is_empty() {
                    None
                } else if locs.len() == 1 {
                    Some(GotoDefinitionResponse::Scalar(
                        backend.translate_location(locs[0].clone()),
                    ))
                } else {
                    Some(GotoDefinitionResponse::Array(
                        locs.into_iter()
                            .map(|l| backend.translate_location(l))
                            .collect(),
                    ))
                }
            })
        })
        .await
        .unwrap_or(Ok(None))
    }

    async fn goto_implementation(
        &self,
        params: GotoImplementationParams,
    ) -> Result<Option<GotoImplementationResponse>> {
        let uri = params
            .text_document_position_params
            .text_document
            .uri
            .to_string();
        let position = params.text_document_position_params.position;
        let token = match params.work_done_progress_params.work_done_token {
            Some(t) => Some(t),
            None => self.progress_create("goto_implementation").await,
        };

        if let Some(ref tok) = token {
            self.progress_begin(tok, "Go to Implementation", Some("Scanning…".to_string()))
                .await;
        }

        // Run on a blocking thread so the async runtime stays free to
        // flush progress notifications to the client.
        //
        // Wrapped in tokio::spawn for cancellation safety (see references handler).
        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        let result = tokio::spawn(async move {
            tokio::task::spawn_blocking(move || {
                backend.handle_with_position(
                    "goto_implementation",
                    &uri_clone,
                    position,
                    |content, pos| {
                        backend
                            .resolve_implementation(&uri_clone, content, pos)
                            .map(|locs| {
                                locs.into_iter()
                                    .map(|l| backend.translate_location(l))
                                    .collect()
                            })
                            .and_then(wrap_locations)
                    },
                )
            })
            .await
            .unwrap_or(Ok(None))
        })
        .await
        .unwrap_or(Ok(None));

        if let Some(ref tok) = token {
            self.progress_end(tok, Some("Done".to_string())).await;
        }

        result
    }

    async fn goto_type_definition(
        &self,
        params: GotoTypeDefinitionParams,
    ) -> Result<Option<GotoTypeDefinitionResponse>> {
        let uri = params
            .text_document_position_params
            .text_document
            .uri
            .to_string();
        let position = params.text_document_position_params.position;

        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        tokio::task::spawn_blocking(move || {
            backend.handle_with_position(
                "goto_type_definition",
                &uri_clone,
                position,
                |content, pos| {
                    backend
                        .resolve_type_definition(&uri_clone, content, pos)
                        .map(|locs| {
                            locs.into_iter()
                                .map(|l| backend.translate_location(l))
                                .collect()
                        })
                        .and_then(wrap_locations)
                },
            )
        })
        .await
        .unwrap_or(Ok(None))
    }

    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
        let uri = params
            .text_document_position_params
            .text_document
            .uri
            .to_string();
        let position = params.text_document_position_params.position;

        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        tokio::task::spawn_blocking(move || {
            // For Blade files, check if the cursor is on a `{{` or `{!!` echo
            // delimiter. If so, return hover for `e()` (escaped echo) or a
            // raw-echo explanation, rather than falling through to the virtual
            // PHP content where the position maps into boilerplate.
            if backend.is_blade_file(&uri_clone)
                && let Some(hover) = backend.blade_echo_delimiter_hover(&uri_clone, position)
            {
                return Ok(Some(hover));
            }

            backend.handle_with_position("hover", &uri_clone, position, |content, pos| {
                let mut hover = backend.handle_hover(&uri_clone, content, pos)?;
                if backend.is_blade_file(&uri_clone)
                    && let Some(range) = &mut hover.range
                {
                    range.start = backend.translate_php_to_blade(&uri_clone, range.start);
                    range.end = backend.translate_php_to_blade(&uri_clone, range.end);
                }
                Some(hover)
            })
        })
        .await
        .unwrap_or(Ok(None))
    }

    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
        self.handle_completion(params).await
    }

    async fn completion_resolve(&self, params: CompletionItem) -> Result<CompletionItem> {
        Ok(self.handle_completion_resolve(params))
    }

    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
        let uri = params.text_document_position.text_document.uri.to_string();
        let position = params.text_document_position.position;
        let include_declaration = params.context.include_declaration;
        let token = match params.work_done_progress_params.work_done_token {
            Some(t) => Some(t),
            None => self.progress_create("find_references").await,
        };

        if let Some(ref tok) = token {
            self.progress_begin(tok, "Find References", Some("Scanning…".to_string()))
                .await;
        }

        // Run on a blocking thread so the async runtime stays free to
        // flush progress notifications to the client.
        //
        // We wrap spawn_blocking inside tokio::spawn so the blocking
        // task is always awaited to completion even if tower-lsp
        // cancels this handler future via $/cancelRequest.  Without
        // this wrapper, dropping the handler future detaches the
        // spawn_blocking JoinHandle, and tower-lsp 0.20 may corrupt
        // its internal state when the orphaned task completes.
        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        let result = tokio::spawn(async move {
            tokio::task::spawn_blocking(move || {
                backend.handle_with_position("references", &uri_clone, position, |content, pos| {
                    backend
                        .find_references(&uri_clone, content, pos, include_declaration)
                        .map(|locs| {
                            locs.into_iter()
                                .map(|l| backend.translate_location(l))
                                .collect()
                        })
                })
            })
            .await
            .unwrap_or(Ok(None))
        })
        .await
        .unwrap_or(Ok(None));

        if let Some(ref tok) = token {
            self.progress_end(tok, Some("Done".to_string())).await;
        }

        result
    }

    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
        let uri = params.text_document.uri.to_string();

        // Code actions are not yet Blade-aware (edits target virtual PHP
        // coordinates and may insert code outside valid PHP regions).
        // Disabled until Phase 2 component support lands.
        if self.is_blade_file(&uri) {
            return Ok(None);
        }

        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        tokio::task::spawn_blocking(move || {
            backend.handle_with_uri("code_action", &uri_clone, |content| {
                let actions = backend.handle_code_action(&uri_clone, content, &params);
                if actions.is_empty() {
                    None
                } else {
                    Some(actions)
                }
            })
        })
        .await
        .unwrap_or(Ok(None))
    }

    async fn code_action_resolve(&self, action: CodeAction) -> Result<CodeAction> {
        let (resolved, republish_uri) = self.resolve_code_action(action);

        // If a PHPStan quickfix was resolved, reassemble and push
        // diagnostics so the cleared diagnostic disappears immediately.
        if let Some(uri_str) = republish_uri {
            self.assemble_and_push(&uri_str).await;
        }

        Ok(resolved)
    }

    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
        let uri = params
            .text_document_position_params
            .text_document
            .uri
            .to_string();
        let position = params.text_document_position_params.position;

        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        tokio::task::spawn_blocking(move || {
            backend.handle_with_position("signature_help", &uri_clone, position, |content, pos| {
                backend.handle_signature_help(&uri_clone, content, pos)
            })
        })
        .await
        .unwrap_or(Ok(None))
    }

    async fn document_highlight(
        &self,
        params: DocumentHighlightParams,
    ) -> Result<Option<Vec<DocumentHighlight>>> {
        let uri = params
            .text_document_position_params
            .text_document
            .uri
            .to_string();
        let position = params.text_document_position_params.position;

        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        tokio::task::spawn_blocking(move || {
            backend.handle_with_position(
                "document_highlight",
                &uri_clone,
                position,
                |content, pos| {
                    backend
                        .handle_document_highlight(&uri_clone, content, pos)
                        .map(|highlights| {
                            highlights
                                .into_iter()
                                .map(|h| {
                                    let mut h = h;
                                    h.range.start =
                                        backend.translate_php_to_blade(&uri_clone, h.range.start);
                                    h.range.end =
                                        backend.translate_php_to_blade(&uri_clone, h.range.end);
                                    h
                                })
                                .collect()
                        })
                },
            )
        })
        .await
        .unwrap_or(Ok(None))
    }

    async fn linked_editing_range(
        &self,
        params: LinkedEditingRangeParams,
    ) -> Result<Option<LinkedEditingRanges>> {
        let uri = params
            .text_document_position_params
            .text_document
            .uri
            .to_string();
        let position = params.text_document_position_params.position;

        self.handle_with_position("linked_editing_range", &uri, position, |content, pos| {
            self.handle_linked_editing_range(&uri, content, pos)
                .map(|mut ler| {
                    ler.ranges = ler
                        .ranges
                        .into_iter()
                        .map(|r| Range {
                            start: self.translate_php_to_blade(&uri, r.start),
                            end: self.translate_php_to_blade(&uri, r.end),
                        })
                        .collect();
                    ler
                })
        })
    }

    async fn prepare_rename(
        &self,
        params: TextDocumentPositionParams,
    ) -> Result<Option<PrepareRenameResponse>> {
        let uri = params.text_document.uri.to_string();
        let position = params.position;

        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        tokio::task::spawn_blocking(move || {
            backend.handle_with_position("prepare_rename", &uri_clone, position, |content, pos| {
                backend
                    .handle_prepare_rename(&uri_clone, content, pos)
                    .map(|res| match res {
                        PrepareRenameResponse::Range(r) => PrepareRenameResponse::Range(Range {
                            start: backend.translate_php_to_blade(&uri_clone, r.start),
                            end: backend.translate_php_to_blade(&uri_clone, r.end),
                        }),
                        PrepareRenameResponse::RangeWithPlaceholder { range, placeholder } => {
                            PrepareRenameResponse::RangeWithPlaceholder {
                                range: Range {
                                    start: backend.translate_php_to_blade(&uri_clone, range.start),
                                    end: backend.translate_php_to_blade(&uri_clone, range.end),
                                },
                                placeholder,
                            }
                        }
                        PrepareRenameResponse::DefaultBehavior { default_behavior } => {
                            PrepareRenameResponse::DefaultBehavior { default_behavior }
                        }
                    })
            })
        })
        .await
        .unwrap_or(Ok(None))
    }

    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
        let uri = params.text_document_position.text_document.uri.to_string();
        let position = params.text_document_position.position;
        let new_name = params.new_name.clone();

        let backend = self.clone_for_blocking();
        let uri_clone = uri.clone();
        tokio::task::spawn_blocking(move || {
            backend.handle_with_position("rename", &uri_clone, position, |content, pos| {
                backend
                    .handle_rename(&uri_clone, content, pos, &new_name)
                    .map(|mut edit| {
                        if let Some(changes) = &mut edit.changes {
                            for (uri, edits) in changes {
                                let uri_str = uri.to_string();
                                if backend.is_blade_file(&uri_str) {
                                    for e in edits {
                                        e.range.start =
                                            backend.translate_php_to_blade(&uri_str, e.range.start);
                                        e.range.end =
                                            backend.translate_php_to_blade(&uri_str, e.range.end);
                                    }
                                }
                            }
                        }
                        edit
                    })
            })
        })
        .await
        .unwrap_or(Ok(None))
    }

    async fn document_symbol(
        &self,
        params: DocumentSymbolParams,
    ) -> Result<Option<DocumentSymbolResponse>> {
        let uri = params.text_document.uri.to_string();

        self.handle_with_uri("document_symbol", &uri, |content| {
            self.handle_document_symbol(&uri, content)
        })
    }

    #[allow(deprecated)] // SymbolInformation::deprecated is deprecated in the LSP types crate
    async fn symbol(
        &self,
        params: WorkspaceSymbolParams,
    ) -> Result<Option<Vec<SymbolInformation>>> {
        Ok(self.handle_workspace_symbol(&params.query))
    }

    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
        let uri = params.text_document.uri.to_string();
        self.handle_with_uri("folding_range", &uri, |content| {
            self.handle_folding_range(content)
        })
    }

    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
        let uri = params.text_document.uri.to_string();
        self.handle_with_uri("code_lens", &uri, |content| {
            self.handle_code_lens(&uri, content)
        })
    }

    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
        let uri = params.text_document.uri.to_string();
        self.handle_with_uri("document_link", &uri, |content| {
            self.handle_document_link(&uri, content)
        })
    }

    async fn selection_range(
        &self,
        params: SelectionRangeParams,
    ) -> Result<Option<Vec<SelectionRange>>> {
        let uri = params.text_document.uri.to_string();
        let positions = params.positions;
        self.handle_with_uri("selection_range", &uri, |content| {
            self.handle_selection_range(content, &positions)
        })
    }

    async fn semantic_tokens_full(
        &self,
        params: SemanticTokensParams,
    ) -> Result<Option<SemanticTokensResult>> {
        let uri = params.text_document.uri.to_string();
        self.handle_with_uri("semantic_tokens_full", &uri, |content| {
            self.handle_semantic_tokens_full(&uri, content)
        })
    }

    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
        self.inlay_hint_request(params).await
    }

    async fn prepare_type_hierarchy(
        &self,
        params: TypeHierarchyPrepareParams,
    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
        let uri = params
            .text_document_position_params
            .text_document
            .uri
            .to_string();
        let position = params.text_document_position_params.position;
        self.handle_with_position("prepare_type_hierarchy", &uri, position, |content, pos| {
            self.prepare_type_hierarchy_impl(&uri, content, pos)
                .map(|items| {
                    items
                        .into_iter()
                        .map(|mut item| {
                            item.range.start = self.translate_php_to_blade(&uri, item.range.start);
                            item.range.end = self.translate_php_to_blade(&uri, item.range.end);
                            item.selection_range.start =
                                self.translate_php_to_blade(&uri, item.selection_range.start);
                            item.selection_range.end =
                                self.translate_php_to_blade(&uri, item.selection_range.end);
                            item
                        })
                        .collect()
                })
        })
    }

    async fn supertypes(
        &self,
        params: TypeHierarchySupertypesParams,
    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
        Ok(self.supertypes_impl(&params.item))
    }

    async fn subtypes(
        &self,
        params: TypeHierarchySubtypesParams,
    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
        let backend = self.clone_for_blocking();
        let item = params.item;
        let token = match params.work_done_progress_params.work_done_token {
            Some(t) => Some(t),
            None => self.progress_create("type_hierarchy_subtypes").await,
        };

        if let Some(ref tok) = token {
            self.progress_begin(tok, "Type Hierarchy", Some("Scanning…".to_string()))
                .await;
        }

        // Wrapped in tokio::spawn for cancellation safety (see references handler).
        let result = tokio::spawn(async move {
            tokio::task::spawn_blocking(move || backend.subtypes_impl(&item))
                .await
                .unwrap_or(None)
        })
        .await
        .unwrap_or(None);

        if let Some(ref tok) = token {
            self.progress_end(tok, Some("Done".to_string())).await;
        }

        Ok(result)
    }

    async fn on_type_formatting(
        &self,
        params: DocumentOnTypeFormattingParams,
    ) -> Result<Option<Vec<TextEdit>>> {
        // Only handle Enter ("\n") for PHPDoc block generation.
        if params.ch != "\n" {
            return Ok(None);
        }

        let uri = params.text_document_position.text_document.uri.to_string();
        let position = params.text_document_position.position;

        let content = match self.get_file_content(&uri) {
            Some(c) => c,
            None => return Ok(None),
        };

        let ctx = self.file_context(&uri);
        let class_loader = self.class_loader(&ctx);
        let function_loader = self.function_loader(&ctx);

        let edits = crate::completion::phpdoc::generation::try_generate_docblock_on_enter(
            &content,
            position,
            &ctx.use_map,
            &ctx.namespace,
            &ctx.classes,
            &class_loader,
            Some(&function_loader),
        );

        Ok(edits)
    }

    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
        let uri = params.text_document.uri.to_string();
        let config = self.config();

        // Read Composer metadata for require-dev detection and bin-dir.
        let workspace_root = self.workspace_root.read().clone();
        let composer_json: Option<composer::ComposerPackage> = workspace_root
            .as_deref()
            .and_then(composer::read_composer_package);
        let bin_dir: Option<String> = composer_json.as_ref().map(composer::get_bin_dir);

        // Resolve the formatting strategy: external tools, built-in, or disabled.
        let strategy = formatting::resolve_strategy(
            workspace_root.as_deref(),
            &config.formatting,
            composer_json.as_ref(),
            bin_dir.as_deref(),
        );

        // Resolve the file path from the URI for config discovery.
        let file_path = Url::parse(&uri).ok().and_then(|u| u.to_file_path().ok());
        let file_path = match file_path {
            Some(p) => p,
            None => return Ok(None),
        };

        // Get the file content.
        let content = match self.get_file_content(&uri) {
            Some(c) => c,
            None => return Ok(None),
        };

        let php_version = self.php_version();

        // Execute the resolved formatting strategy on a blocking thread
        // to avoid stalling the async runtime while external tools run.
        let formatting_config = config.formatting.clone();
        let result = tokio::task::spawn_blocking(move || {
            formatting::execute_strategy(
                &strategy,
                &content,
                &file_path,
                &formatting_config,
                php_version,
            )
        })
        .await;

        match result {
            Ok(Ok(edits)) => Ok(edits),
            Ok(Err(e)) => {
                self.log(MessageType::ERROR, format!("Formatting failed: {}", e))
                    .await;
                Err(tower_lsp::jsonrpc::Error {
                    code: tower_lsp::jsonrpc::ErrorCode::InternalError,
                    message: format!("Formatting failed: {}", e).into(),
                    data: None,
                })
            }
            Err(join_err) => {
                let msg = format!("Formatting task panicked: {}", join_err);
                self.log(MessageType::ERROR, msg.clone()).await;
                Err(tower_lsp::jsonrpc::Error {
                    code: tower_lsp::jsonrpc::ErrorCode::InternalError,
                    message: msg.into(),
                    data: None,
                })
            }
        }
    }

    async fn diagnostic(
        &self,
        params: DocumentDiagnosticParams,
    ) -> Result<DocumentDiagnosticReportResult> {
        let uri_str = params.text_document.uri.to_string();

        // Check resultId — if the client sends back the same resultId we
        // last returned AND the full cache is still present (not
        // invalidated by schedule_diagnostics), the diagnostics have not
        // changed and we can return Unchanged immediately.
        let cache_present = self.diag_last_full.lock().contains_key(&uri_str);
        if cache_present && let Some(prev_id) = &params.previous_result_id {
            let ids = self.diag_result_ids.lock();
            if let Some(&current_id) = ids.get(&uri_str)
                && prev_id == &current_id.to_string()
            {
                return Ok(DocumentDiagnosticReportResult::Report(
                    DocumentDiagnosticReport::Unchanged(RelatedUnchangedDocumentDiagnosticReport {
                        related_documents: None,
                        unchanged_document_diagnostic_report: UnchangedDocumentDiagnosticReport {
                            result_id: current_id.to_string(),
                        },
                    }),
                ));
            }
        }

        // In pull mode the pull request *triggers* native diagnostic
        // computation (no debounce — the IDE decided "now is the time").
        // If the full cache is missing for this URI, run the native
        // pipeline immediately and block until it finishes.  External
        // tool results (PHPStan, PHPCS, Mago) are delivered
        // incrementally via publishDiagnostics as each finishes; we do
        // not block on them here to keep the pull response fast.
        let needs_compute = {
            let cache = self.diag_last_full.lock();
            !cache.contains_key(&uri_str)
        };

        if needs_compute {
            self.trigger_diagnostics_for_pull(&uri_str).await;
        }

        let (diagnostics, result_id) = {
            let cache = self.diag_last_full.lock();
            let ids = self.diag_result_ids.lock();
            let diags = cache.get(&uri_str).cloned().unwrap_or_default();
            let rid = ids.get(&uri_str).copied().unwrap_or(0).to_string();
            (diags, rid)
        };

        Ok(DocumentDiagnosticReportResult::Report(
            DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
                related_documents: None,
                full_document_diagnostic_report: FullDocumentDiagnosticReport {
                    result_id: Some(result_id),
                    items: diagnostics,
                },
            }),
        ))
    }

    async fn workspace_diagnostic(
        &self,
        params: WorkspaceDiagnosticParams,
    ) -> Result<WorkspaceDiagnosticReportResult> {
        // Build a set of previous result IDs sent by the client so we
        // can return Unchanged for files that haven't changed.
        let previous: HashMap<&str, &str> = params
            .previous_result_ids
            .iter()
            .map(|p| (p.uri.as_str(), p.value.as_str()))
            .collect();

        let open_uris: Vec<String> = {
            let files = self.open_files.read();
            files.keys().cloned().collect()
        };

        let mut items = Vec::new();

        for uri_str in &open_uris {
            // Read the current resultId for this file.
            let current_id = {
                let ids = self.diag_result_ids.lock();
                ids.get(uri_str.as_str()).copied().unwrap_or(0)
            };

            // Check if the client already has up-to-date diagnostics.
            // The resultId must match AND the full cache must be present.
            // When `schedule_diagnostics` invalidates the cache (removing
            // diag_last_full), the resultId is intentionally kept so it
            // doesn't reset to 0.  But we must not return "unchanged"
            // when the cache is missing — that means fresh computation
            // is needed.
            let cache_present = self.diag_last_full.lock().contains_key(uri_str.as_str());
            if cache_present
                && let Some(prev_id) = previous.get(uri_str.as_str())
                && *prev_id == current_id.to_string()
            {
                let uri = match uri_str.parse::<Url>() {
                    Ok(u) => u,
                    Err(_) => continue,
                };
                items.push(WorkspaceDocumentDiagnosticReport::Unchanged(
                    WorkspaceUnchangedDocumentDiagnosticReport {
                        uri,
                        version: None,
                        unchanged_document_diagnostic_report: UnchangedDocumentDiagnosticReport {
                            result_id: current_id.to_string(),
                        },
                    },
                ));
                continue;
            }

            // If the cache is missing, trigger computation (same as
            // textDocument/diagnostic).
            let needs_compute = {
                let cache = self.diag_last_full.lock();
                !cache.contains_key(uri_str.as_str())
            };
            if needs_compute {
                self.trigger_diagnostics_for_pull(uri_str).await;
            }

            let diagnostics = {
                let cache = self.diag_last_full.lock();
                cache.get(uri_str.as_str()).cloned().unwrap_or_default()
            };

            // Re-read the resultId after potential computation.
            let current_id = {
                let ids = self.diag_result_ids.lock();
                ids.get(uri_str.as_str()).copied().unwrap_or(0)
            };

            let uri = match uri_str.parse::<Url>() {
                Ok(u) => u,
                Err(_) => continue,
            };

            items.push(WorkspaceDocumentDiagnosticReport::Full(
                WorkspaceFullDocumentDiagnosticReport {
                    uri,
                    version: None,
                    full_document_diagnostic_report: FullDocumentDiagnosticReport {
                        result_id: Some(current_id.to_string()),
                        items: diagnostics,
                    },
                },
            ));
        }

        Ok(WorkspaceDiagnosticReportResult::Report(
            WorkspaceDiagnosticReport { items },
        ))
    }
}

/// Convert a `Vec<Location>` into a `GotoDefinitionResponse`.
///
/// Returns `Scalar` for a single location, `Array` for multiple, and
/// `None` for an empty vec.  This is used by `goto_implementation` and
/// `goto_type_definition` which both share this pattern.
fn wrap_locations(locations: Vec<Location>) -> Option<GotoDefinitionResponse> {
    match locations.len() {
        0 => None,
        1 => Some(GotoDefinitionResponse::Scalar(
            locations.into_iter().next().unwrap(),
        )),
        _ => Some(GotoDefinitionResponse::Array(locations)),
    }
}

// ─── Self-scan helpers ──────────────────────────────────────────────────────

impl Backend {
    /// Fetch the open-file content for `uri`, run `f` inside a panic
    /// guard, and return the result.
    ///
    /// Returns `None` when the file is not open or when `f` panics.
    /// Most LSP handlers follow the pattern "get content, run handler
    /// with panic protection, return result" — this helper captures
    /// that boilerplate in one place.
    pub(crate) fn with_file_content<T>(
        &self,
        handler_name: &str,
        uri: &str,
        position: Option<Position>,
        f: impl FnOnce(&str, Option<Position>) -> T,
    ) -> Option<T> {
        let mut content = self.get_file_content(uri)?;
        let mut pos = position;

        // If this is a Blade file, use the virtual PHP content and translate the position.
        if self.is_blade_file(uri)
            && let Some(virtual_content) = self.blade_virtual_content.read().get(uri)
        {
            content = virtual_content.clone();
            if let Some(p) = position {
                pos = Some(self.translate_blade_to_php(uri, p));
            }
        }

        // Activate the chain resolution cache so that shared chain prefixes
        // (e.g. `$model->where(...)` in `$model->where(...)->orderBy(...)`)
        // are resolved once and reused across all LSP handlers, not just
        // diagnostics.  The guard is re-entrant safe: if a diagnostic pass
        // already activated the cache, this is a no-op.
        let _chain_guard = crate::completion::resolver::with_chain_resolution_cache();
        crate::util::catch_panic_unwind_safe(handler_name, uri, pos, || f(&content, pos))
    }

    /// Position-based handler helper. Extracts the URI and position from
    /// the params, fetches file content, runs the closure inside a panic
    /// guard, and flattens the nested `Option`.
    ///
    /// Covers the majority of LSP handlers that take a
    /// `TextDocumentPositionParams` and return `Option<T>`.
    fn handle_with_position<T>(
        &self,
        handler_name: &str,
        uri: &str,
        position: Position,
        f: impl FnOnce(&str, Position) -> Option<T>,
    ) -> Result<Option<T>> {
        Ok(self
            .with_file_content(handler_name, uri, Some(position), |content, pos| {
                f(content, pos.unwrap())
            })
            .flatten())
    }

    /// URI-only handler helper. Like [`handle_with_position`] but for
    /// handlers that only need the document URI (no cursor position).
    fn handle_with_uri<T>(
        &self,
        handler_name: &str,
        uri: &str,
        f: impl FnOnce(&str) -> Option<T>,
    ) -> Result<Option<T>> {
        Ok(self
            .with_file_content(handler_name, uri, None, |content, _| f(content))
            .flatten())
    }

    // ── Initialization helpers ───────────────────────────────────────────

    /// Initialize a single-project workspace (root `composer.json` exists).
    ///
    /// This is the standard fast path: read PSR-4 mappings, build the
    /// classmap, scan autoload files.  Unchanged from the pre-monorepo
    /// behaviour except that vendor fields are now collections.
    pub(crate) async fn init_single_project(
        &self,
        root: &std::path::Path,
        php_version: crate::types::PhpVersion,
        composer_json: Option<composer::ComposerPackage>,
        progress_token: Option<&NumberOrString>,
    ) {
        if let Some(tok) = progress_token {
            self.progress_report(tok, 10, Some("Reading composer.json".to_string()))
                .await;
        }

        let (mappings, vendor_dir) = match &composer_json {
            Some(pkg) => {
                let mappings = composer::extract_psr4_mappings_from_package(pkg);
                let vendor_dir = composer::get_vendor_dir(pkg);
                (mappings, vendor_dir)
            }
            None => (Vec::new(), "vendor".to_string()),
        };

        // Cache the vendor dir path so cross-file scans can skip it
        // without re-reading composer.json on every request.
        let vendor_path = root.join(&vendor_dir);
        self.add_vendor_dir(&vendor_path);

        *self.psr4_mappings.write() = mappings;

        // ── Build the classmap ──────────────────────────────────────
        let strategy = self.config().indexing.strategy();

        if let Some(tok) = progress_token {
            self.progress_report(tok, 20, Some("Building class index".to_string()))
                .await;
        }

        let (classmap, source_label) = match strategy {
            IndexingStrategy::None => {
                let cm = composer::parse_autoload_classmap(root, &vendor_dir);
                (cm, "composer")
            }
            IndexingStrategy::SelfScan | IndexingStrategy::Full => {
                // "self" strategy: scan every PHP file under the
                // workspace root (ignoring .gitignore, hidden dirs,
                // etc.) to discover all classes, functions, and
                // constants — regardless of whether they appear in
                // composer.json's autoload sections.
                //
                // Explicitly skip the vendor directory so it is never
                // walked even when it is not in .gitignore.  Vendor
                // packages are scanned separately via installed.json
                // so that third-party classes are still indexed.
                let mut skip_dirs = HashSet::new();
                skip_dirs.insert(vendor_path.clone());
                let mut scan = classmap_scanner::scan_workspace_fallback_full(root, &skip_dirs);

                // Merge vendor packages (excluded from the workspace
                // walk above, scanned separately here).
                let vendor_scan = classmap_scanner::scan_vendor_packages(root, &vendor_dir);
                for (fqcn, path) in vendor_scan.classmap {
                    scan.classmap.entry(fqcn).or_insert(path);
                }
                for (fqn, path) in vendor_scan.function_index {
                    scan.function_index.entry(fqn).or_insert(path);
                }
                for (name, path) in vendor_scan.constant_index {
                    scan.constant_index.entry(name).or_insert(path);
                }

                self.populate_autoload_indices(&scan);
                (scan.classmap, "self-scan")
            }
            IndexingStrategy::Composer => {
                // ── Merged classmap + self-scan pipeline ─────────────
                let composer_cm = composer::parse_autoload_classmap(root, &vendor_dir);
                let skip_paths: HashSet<PathBuf> = composer_cm.values().cloned().collect();
                let scan = self.build_self_scan_composer(
                    root,
                    &vendor_dir,
                    composer_json.as_ref(),
                    &skip_paths,
                );
                self.populate_autoload_indices(&scan);
                let mut merged = composer_cm;
                for (fqcn, path) in scan.classmap {
                    merged.entry(fqcn).or_insert(path);
                }
                (merged, "composer+scan")
            }
        };

        let symbol_count = classmap.len();
        {
            let mut idx = self.fqn_uri_index.write();
            for (fqn, path) in classmap {
                idx.entry(fqn)
                    .or_insert_with(|| crate::util::path_to_uri(&path));
            }
        }

        // ── Drupal: scan web-root directories (gitignore bypassed) ──
        // Drupal's .gitignore excludes web/core, web/modules/contrib,
        // etc. because they are managed by Composer — but those paths
        // contain every base interface and hook definition that modules
        // depend on.  detect_drupal_web_root() returns None for
        // non-Drupal projects so this block is a no-op in that case.
        if let Some(ref pkg) = composer_json
            && let Some(drupal_web_root) = composer::detect_drupal_web_root(root, pkg)
        {
            let drupal_result = classmap_scanner::scan_drupal_directories(&drupal_web_root);
            let drupal_count = drupal_result.classmap.len()
                + drupal_result.function_index.len()
                + drupal_result.constant_index.len();
            {
                let mut idx = self.fqn_uri_index.write();
                for (fqn, path) in drupal_result.classmap {
                    idx.entry(fqn)
                        .or_insert_with(|| crate::util::path_to_uri(&path));
                }
            }
            {
                let mut fi = self.autoload_function_index.write();
                for (fqn, path) in drupal_result.function_index {
                    fi.entry(fqn).or_insert(path);
                }
            }
            {
                let mut ci = self.autoload_constant_index.write();
                for (name, path) in drupal_result.constant_index {
                    ci.entry(name).or_insert(path);
                }
            }
            tracing::info!(
                "PHPantom: Drupal web root {:?}, {} symbols indexed",
                drupal_web_root,
                drupal_count
            );
        }

        // ── PSR-0 (legacy) classmap ─────────────────────────────────
        // Packages that declare `autoload.psr-0` in their composer.json
        // (e.g. HTMLPurifier) are listed in `autoload_namespaces.php`.
        // Scan the listed directories and merge discovered classes into
        // the classmap so they are resolvable via `find_or_load_class`.
        let psr0_cm = composer::parse_autoload_namespaces(root, &vendor_dir);
        if !psr0_cm.is_empty() {
            let count = psr0_cm.len();
            let mut idx = self.fqn_uri_index.write();
            for (fqn, path) in psr0_cm {
                idx.entry(fqn)
                    .or_insert_with(|| crate::util::path_to_uri(&path));
            }
            tracing::info!("PSR-0: {} classes from autoload_namespaces.php", count);
        }

        // ── Autoload files ──────────────────────────────────────────
        if let Some(tok) = progress_token {
            self.progress_report(tok, 70, Some("Scanning autoload files".to_string()))
                .await;
        }

        self.scan_autoload_files(root, &vendor_dir);

        let symbol_count = symbol_count
            + self.autoload_function_index.read().len()
            + self.autoload_constant_index.read().len();

        self.log(
            MessageType::INFO,
            format!(
                "PHPantom v{}: PHP {}, {} symbols from {}, stubs {}",
                self.version,
                php_version,
                symbol_count,
                source_label,
                crate::stubs::STUBS_VERSION
            ),
        )
        .await;
    }

    /// Initialize a monorepo workspace (no root `composer.json`, but
    /// subprojects with their own `composer.json` were discovered).
    ///
    /// Each subproject is processed through the Composer pipeline (PSR-4,
    /// classmap, autoload files, vendor packages).  After all subprojects
    /// are processed, a gitignore-aware full-scan picks up loose PHP files
    /// outside any subproject directory.
    async fn init_monorepo(
        &self,
        root: &std::path::Path,
        subprojects: &[(PathBuf, String)],
        php_version: crate::types::PhpVersion,
        progress_token: Option<&NumberOrString>,
    ) {
        // Log the discovered subprojects.
        let sub_list: Vec<String> = subprojects
            .iter()
            .filter_map(|(p, _)| {
                p.strip_prefix(root)
                    .ok()
                    .map(|r| format!("  {}", r.display()))
            })
            .collect();
        self.log(
            MessageType::INFO,
            format!(
                "PHPantom: No root composer.json. Found {} Composer project(s):\n{}",
                subprojects.len(),
                sub_list.join("\n")
            ),
        )
        .await;

        // Collect subproject root paths for the skip set.
        let mut skip_dirs: HashSet<PathBuf> = HashSet::new();
        let sub_count = subprojects.len();

        for (sub_idx, (sub_root, vendor_dir)) in subprojects.iter().enumerate() {
            // Report per-subproject progress.  Reserve 10..80 for the
            // subproject loop, leaving 80..95 for the loose-file scan.
            if let Some(tok) = progress_token {
                let pct = 10 + (sub_idx as u32 * 70) / sub_count.max(1) as u32;
                let label = sub_root
                    .strip_prefix(root)
                    .unwrap_or(sub_root)
                    .display()
                    .to_string();
                self.progress_report(
                    tok,
                    pct,
                    Some(format!(
                        "Indexing subproject {} / {}: {}",
                        sub_idx + 1,
                        sub_count,
                        label
                    )),
                )
                .await;
            }
            skip_dirs.insert(sub_root.clone());

            // ── PSR-4 mappings ──────────────────────────────────────
            let (mappings, _) = composer::parse_composer_json(sub_root);

            // Resolve base_path values to absolute paths so that
            // resolve_class_path works regardless of workspace_root.
            let abs_mappings: Vec<composer::Psr4Mapping> = mappings
                .into_iter()
                .map(|m| {
                    let abs_base = sub_root.join(&m.base_path).to_string_lossy().to_string();
                    composer::Psr4Mapping {
                        prefix: m.prefix,
                        base_path: composer::normalise_path(&abs_base),
                    }
                })
                .collect();
            {
                let mut psr4 = self.psr4_mappings.write();
                psr4.extend(abs_mappings);
            }

            // ── Vendor dir tracking ─────────────────────────────────
            let vendor_path = sub_root.join(vendor_dir);
            self.add_vendor_dir(&vendor_path);

            // ── Autoload files ──────────────────────────────────────
            self.scan_autoload_files(sub_root, vendor_dir);

            // ── Merged classmap + self-scan ──────────────────────────
            // Load the subproject's Composer classmap as a skip set,
            // then self-scan its PSR-4 directories and vendor packages
            // for anything the classmap missed.
            let mut sub_cm = composer::parse_autoload_classmap(sub_root, vendor_dir);
            // Merge PSR-0 classes for this subproject.
            let psr0_cm = composer::parse_autoload_namespaces(sub_root, vendor_dir);
            for (fqn, path) in psr0_cm {
                sub_cm.entry(fqn).or_insert(path);
            }
            let sub_skip: HashSet<PathBuf> = sub_cm.values().cloned().collect();
            let scan = self.build_self_scan_composer(sub_root, vendor_dir, None, &sub_skip);
            self.populate_autoload_indices(&scan);
            {
                let mut idx = self.fqn_uri_index.write();
                for (fqcn, path) in sub_cm {
                    idx.entry(fqcn)
                        .or_insert_with(|| crate::util::path_to_uri(&path));
                }
                for (fqcn, path) in scan.classmap {
                    idx.entry(fqcn)
                        .or_insert_with(|| crate::util::path_to_uri(&path));
                }
            }
        }

        // Re-sort PSR-4 mappings by prefix length descending so
        // longest-prefix-first matching works.
        {
            let mut psr4 = self.psr4_mappings.write();
            psr4.sort_by_key(|b| std::cmp::Reverse(b.prefix.len()));
        }

        // ── Full-scan loose files ───────────────────────────────────
        // Walk the workspace for PHP files outside any subproject
        // directory, using gitignore-aware walking.
        if let Some(tok) = progress_token {
            self.progress_report(tok, 80, Some("Scanning loose PHP files".to_string()))
                .await;
        }

        let scan = classmap_scanner::scan_workspace_fallback_full(root, &skip_dirs);
        self.populate_autoload_indices(&scan);
        {
            let mut idx = self.fqn_uri_index.write();
            for (fqcn, path) in scan.classmap {
                idx.entry(fqcn)
                    .or_insert_with(|| crate::util::path_to_uri(&path));
            }
        }

        let symbol_count = self.fqn_uri_index.read().len()
            + self.autoload_function_index.read().len()
            + self.autoload_constant_index.read().len();

        self.log(
            MessageType::INFO,
            format!(
                "PHPantom v{}: PHP {}, {} symbols from {} subprojects, stubs {}",
                self.version,
                php_version,
                symbol_count,
                subprojects.len(),
                crate::stubs::STUBS_VERSION
            ),
        )
        .await;
    }

    /// Initialize a pure non-Composer workspace (no `composer.json`
    /// anywhere).  Full-scans all PHP files in the workspace.
    async fn init_no_composer(
        &self,
        root: &std::path::Path,
        php_version: crate::types::PhpVersion,
        progress_token: Option<&NumberOrString>,
    ) {
        self.log(
            MessageType::INFO,
            "PHPantom: No composer.json found. Scanning workspace for PHP classes.".to_string(),
        )
        .await;

        if let Some(tok) = progress_token {
            self.progress_report(
                tok,
                20,
                Some("Scanning workspace for PHP files".to_string()),
            )
            .await;
        }

        let skip_dirs = HashSet::new();
        let scan = classmap_scanner::scan_workspace_fallback_full(root, &skip_dirs);
        self.populate_autoload_indices(&scan);

        let symbol_count = scan.classmap.len();
        {
            let mut idx = self.fqn_uri_index.write();
            for (fqn, path) in scan.classmap {
                idx.entry(fqn)
                    .or_insert_with(|| crate::util::path_to_uri(&path));
            }
        }

        let symbol_count = symbol_count
            + self.autoload_function_index.read().len()
            + self.autoload_constant_index.read().len();

        self.log(
            MessageType::INFO,
            format!(
                "PHPantom v{}: PHP {}, {} symbols from workspace scan, stubs {}",
                self.version,
                php_version,
                symbol_count,
                crate::stubs::STUBS_VERSION
            ),
        )
        .await;
    }

    /// Register a vendor directory path and its URI prefix for
    /// vendor-file detection.
    pub(crate) fn add_vendor_dir(&self, vendor_path: &std::path::Path) {
        // Store the absolute path for filesystem-level skip logic.
        {
            let mut paths = self.vendor_dir_paths.lock();
            paths.push(vendor_path.to_path_buf());
        }
        // Store the URI prefix for URI-level skip logic (diagnostics,
        // find references, rename).
        let prefix = if let Ok(canonical) = vendor_path.canonicalize() {
            format!("{}/", crate::util::path_to_uri(&canonical))
        } else {
            format!("{}/", crate::util::path_to_uri(vendor_path))
        };
        {
            let mut prefixes = self.vendor_uri_prefixes.lock();
            prefixes.push(prefix);
        }
    }

    /// Scan autoload files for a single project root and populate the
    /// autoload indices.  Returns the number of autoload file entries
    /// found.
    pub(crate) fn scan_autoload_files(
        &self,
        project_root: &std::path::Path,
        vendor_dir: &str,
    ) -> usize {
        let autoload_files = composer::parse_autoload_files(project_root, vendor_dir);
        let autoload_count = autoload_files.len();

        // Work queue + visited set for following require_once chains.
        let mut file_queue: Vec<PathBuf> = autoload_files;
        let mut visited: HashSet<PathBuf> = HashSet::new();

        while let Some(file_path) = file_queue.pop() {
            // Canonicalise to avoid revisiting the same file via
            // different relative paths.
            let canonical = file_path.canonicalize().unwrap_or(file_path);
            if !visited.insert(canonical.clone()) {
                continue;
            }

            if let Ok(content) = std::fs::read(&canonical) {
                let uri = crate::util::path_to_uri(&canonical);

                // Lightweight byte-level scan: extract symbol names
                // without building a full AST.
                let scan = classmap_scanner::find_symbols(&content);

                // Populate function index.
                {
                    let mut idx = self.autoload_function_index.write();
                    for fqn in &scan.functions {
                        idx.entry(fqn.clone()).or_insert_with(|| canonical.clone());
                    }
                }

                // Populate constant index.
                {
                    let mut idx = self.autoload_constant_index.write();
                    for name in &scan.constants {
                        idx.entry(name.clone()).or_insert_with(|| canonical.clone());
                    }
                }

                // Populate fqn_uri_index so find_or_load_class can
                // lazily parse these classes later.
                {
                    let mut idx = self.fqn_uri_index.write();
                    for fqn in &scan.classes {
                        idx.entry(fqn.clone()).or_insert_with(|| uri.clone());
                    }
                }

                let content_str = String::from_utf8_lossy(&content);

                // ── Phar detection ──────────────────────────────────
                // If this autoload file references a .phar archive,
                // parse the phar and scan its PHP files for classes.
                if let Some(file_dir) = canonical.parent() {
                    let phar_paths = composer::detect_phar_references(&content_str, file_dir);
                    for phar_path in phar_paths {
                        self.scan_phar_archive(&phar_path);
                    }
                }

                // Follow require_once statements to discover more files.
                let require_paths = composer::extract_require_once_paths(&content_str);
                if let Some(file_dir) = canonical.parent() {
                    for rel_path in require_paths {
                        let resolved = file_dir.join(&rel_path);
                        if resolved.is_file() {
                            file_queue.push(resolved);
                        }
                    }
                }
            }
        }

        // Store the visited autoload file paths for last-resort lazy
        // parsing of guarded functions.
        {
            let mut paths = self.autoload_file_paths.write();
            paths.extend(visited);
        }

        autoload_count
    }

    /// Parse a `.phar` archive and register its PHP classes in the
    /// fqn_uri_index for lazy loading.
    ///
    /// The phar's raw bytes are read from disk, parsed by
    /// [`phar::PharArchive`], and stored in
    /// [`phar_archives`](crate::Backend::phar_archives).  Each `.php`
    /// file inside the archive is scanned with the lightweight
    /// [`find_classes`](classmap_scanner::find_classes) byte scanner,
    /// and discovered classes are registered in:
    ///
    /// - `fqn_uri_index` — with a sentinel path like
    ///   `/path/to/phpstan.phar!src/Type/Type.php` (the `!` separator
    ///   tells [`parse_and_cache_file`](crate::Backend::parse_and_cache_file)
    ///   to extract content from the phar instead of reading from disk)
    ///   and a `phar://` URI for completions and workspace symbols
    fn scan_phar_archive(&self, phar_path: &Path) {
        // Avoid scanning the same phar twice.
        if self.phar_archives.read().contains_key(phar_path) {
            return;
        }

        let data = match std::fs::read(phar_path) {
            Ok(d) => d,
            Err(_) => return,
        };

        let archive = match phar::PharArchive::parse(data) {
            Some(a) => a,
            None => {
                tracing::warn!("failed to parse phar archive: {}", phar_path.display());
                return;
            }
        };

        // Collect PHP file paths first so we can iterate while
        // holding the archive reference.
        let php_files: Vec<String> = archive
            .file_paths()
            .filter(|p| p.ends_with(".php"))
            .map(String::from)
            .collect();

        let mut classmap_entries: Vec<(String, PathBuf)> = Vec::new();
        let mut fqn_uri_entries: Vec<(String, String)> = Vec::new();

        for internal_path in &php_files {
            if let Some(content) = archive.read_file(internal_path) {
                let classes = classmap_scanner::find_classes(content);
                for fqn in classes {
                    // Sentinel path: "archive.phar!internal/path.php"
                    let sentinel =
                        PathBuf::from(format!("{}!{}", phar_path.display(), internal_path));
                    let phar_uri = format!("phar://{}/{}", phar_path.display(), internal_path);
                    classmap_entries.push((fqn.clone(), sentinel));
                    fqn_uri_entries.push((fqn, phar_uri));
                }
            }
        }

        let class_count = classmap_entries.len();

        // Register classes in the fqn_uri_index.
        {
            let mut idx = self.fqn_uri_index.write();
            for (fqn, path) in classmap_entries {
                idx.entry(fqn)
                    .or_insert_with(|| crate::util::path_to_uri(&path));
            }
            for (fqn, uri) in fqn_uri_entries {
                idx.entry(fqn).or_insert(uri);
            }
        }

        // Clear the negative class cache so that classes previously
        // looked up (and cached as "not found") before the phar was
        // scanned can now be resolved.
        if class_count > 0 {
            self.class_not_found_cache.write().clear();
        }

        tracing::info!(
            "scanned phar {}: {} PHP files, {} classes",
            phar_path.display(),
            php_files.len(),
            class_count,
        );

        // Store the parsed archive for lazy content extraction.
        self.phar_archives
            .write()
            .insert(phar_path.to_owned(), archive);
    }

    /// Build a workspace scan by self-scanning a Composer project's
    /// autoload directories (PSR-4 + classmap + vendor packages).
    ///
    /// Used by the merged classmap + self-scan pipeline and by the
    /// `"self"` / `"full"` indexing strategies.  The `project_root`
    /// is the directory containing `composer.json` (either the
    /// workspace root for single-project, or a subproject root for
    /// monorepo).
    ///
    /// `skip_paths` contains absolute file paths that should be
    /// excluded from scanning (typically the file paths already
    /// present in the Composer classmap).  Pass an empty set to
    /// scan everything.
    pub(crate) fn build_self_scan_composer(
        &self,
        project_root: &std::path::Path,
        vendor_dir: &str,
        preloaded_package: Option<&composer::ComposerPackage>,
        skip_paths: &HashSet<PathBuf>,
    ) -> WorkspaceScanResult {
        // Use the pre-parsed package when available; only read from disk
        // as a fallback (e.g. monorepo subproject calls).
        let owned_package;
        let package = match preloaded_package {
            Some(p) => p,
            None => {
                owned_package = composer::read_composer_package(project_root);
                match owned_package.as_ref() {
                    Some(p) => p,
                    None => {
                        let skip_dirs = HashSet::new();
                        return classmap_scanner::scan_workspace_fallback_full(
                            project_root,
                            &skip_dirs,
                        );
                    }
                }
            }
        };

        let scan_dirs = composer::extract_scan_dirs(package);

        let psr4_dirs: Vec<(String, PathBuf)> = scan_dirs
            .psr4
            .iter()
            .map(|(prefix, dir)| (prefix.clone(), project_root.join(dir)))
            .collect();

        let classmap_dirs: Vec<PathBuf> = scan_dirs
            .classmap
            .iter()
            .map(|dir| project_root.join(dir))
            .collect();

        // Scan user source directories (classes only for PSR-4).
        let vendor_dir_paths = vec![project_root.join(vendor_dir)];
        let classmap = classmap_scanner::scan_psr4_directories_with_skip(
            &psr4_dirs,
            &classmap_dirs,
            &vendor_dir_paths,
            skip_paths,
        );

        // Scan vendor packages from installed.json.
        let vendor_scan =
            classmap_scanner::scan_vendor_packages_with_skip(project_root, vendor_dir, skip_paths);

        let mut result = WorkspaceScanResult {
            classmap,
            ..Default::default()
        };

        for (fqcn, path) in vendor_scan.classmap {
            result.classmap.entry(fqcn).or_insert(path);
        }
        for (fqn, path) in vendor_scan.function_index {
            result.function_index.entry(fqn).or_insert(path);
        }
        for (name, path) in vendor_scan.constant_index {
            result.constant_index.entry(name).or_insert(path);
        }

        result
    }

    /// Store the function and constant indices from a workspace scan
    /// into the backend's shared maps.
    ///
    /// Only has an effect for non-Composer projects (the "no
    /// `composer.json`" scenario) where the full-scan populates
    /// function and constant entries.  For Composer projects the scan
    /// result's function and constant indices are empty because those
    /// symbols are discovered via the `autoload_files.php` scan loop
    /// in `initialized()` instead.
    pub(crate) fn populate_autoload_indices(&self, scan: &WorkspaceScanResult) {
        if !scan.function_index.is_empty() {
            let mut idx = self.autoload_function_index.write();
            for (fqn, path) in &scan.function_index {
                idx.entry(fqn.clone()).or_insert_with(|| path.clone());
            }
        }
        if !scan.constant_index.is_empty() {
            let mut idx = self.autoload_constant_index.write();
            for (name, path) in &scan.constant_index {
                idx.entry(name.clone()).or_insert_with(|| path.clone());
            }
        }
    }
}
