//! Session-based analysis API for incremental, per-file analysis.
//!
//! [`AnalysisSession`] owns the salsa database and per-session caches for a
//! long-running analysis context shared across many per-file analyses. Reads
//! clone the database under a brief lock, then run lock-free; writes hold the
//! lock briefly to mutate canonical state. `MirDbStorage::clone()` is cheap
//! (Arc-wrapped registries), so this pattern gives parallel readers without
//! blocking on concurrent writes for longer than the clone itself.
//!
//! See [`crate::file_analyzer::FileAnalyzer`] for the per-file analysis
//! entry point that operates against a session.

use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::path::PathBuf;
use std::sync::Arc;

use parking_lot::RwLock;

use crate::analyzer_db::AnalyzerDb;
use crate::cache::AnalysisCache;
use crate::composer::Psr4Map;
use crate::db::{MirDatabase, MirDbStorage, RefLoc};
use crate::php_version::PhpVersion;

/// Long-lived analysis context. Owns the salsa database and tracks which
/// stubs have been loaded.
///
/// Cheap to clone the inner db for parallel reads; writes funnel through
/// [`Self::ingest_file`], [`Self::invalidate_file`], and the crate-internal
/// [`Self::with_db_mut`].
#[derive(Clone)]
pub struct AnalysisSession {
    /// Shared database management (salsa, file registry, stub tracking).
    pub(crate) db: Arc<AnalyzerDb>,
    pub(crate) cache: Option<Arc<AnalysisCache>>,
    /// PSR-4 / Composer autoload map. Retained alongside `resolver` so the
    /// `psr4()` accessor can still return a typed `Psr4Map` for callers that
    /// need Composer-specific data (project_files / vendor_files / etc.).
    pub(crate) psr4: Option<Arc<Psr4Map>>,
    /// Generic class resolver used for on-demand lazy loading. When `psr4`
    /// is set via [`Self::with_psr4`], this is populated with the same map
    /// re-typed as `dyn ClassResolver`. Consumers can also supply their own
    /// resolver via [`Self::with_class_resolver`] without going through
    /// Composer.
    resolver: Option<Arc<dyn crate::ClassResolver>>,
    pub(crate) php_version: PhpVersion,
    pub(crate) user_stub_files: Vec<PathBuf>,
    pub(crate) user_stub_dirs: Vec<PathBuf>,
    /// Tracks symbols that were previously defined in a file but have since
    /// been removed (deleted or renamed). When `ingest_file` detects that
    /// a symbol disappears, it records it here so `dependency_graph()` can
    /// still produce edges to files that reference the now-gone symbol.
    ///
    /// Keyed by the file that used to define the symbols. Symbols are removed
    /// from the set when re-added to the same file on a subsequent ingest.
    /// The set may contain symbols with no current referencers; those are
    /// harmless — the `symbol_referencers_of` lookup returns empty.
    stale_defined_symbols: Arc<RwLock<HashMap<String, HashSet<Arc<str>>>>>,
    /// Negative cache: FQCNs that `load_class` already failed on.
    /// The value is the resolver-mapped path (when known) so eviction on
    /// `set_file_text` / `ingest_file` is a path equality check rather than
    /// re-running the resolver per entry. `None` means the resolver itself
    /// couldn't map the FQCN; those entries survive file edits (no source
    /// change makes a never-resolvable name resolvable).
    /// Bounded to `UNRESOLVABLE_CACHE_CAP`; clears on overflow.
    unresolvable_fqcns: UnresolvableCache,
    /// Pluggable source-text provider for lazy-load. Defaults to filesystem
    /// reads ([`crate::FsSourceProvider`]); LSPs swap in a VFS-backed
    /// implementation so unsaved buffers override on-disk content.
    source_provider: Arc<dyn crate::SourceProvider>,
}

/// FQCN → optional resolver-mapped path. See the field doc on
/// `AnalysisSession::unresolvable_fqcns`.
type UnresolvableCache = Arc<RwLock<HashMap<Arc<str>, Option<Arc<str>>>>>;

/// Cap on the negative-resolution cache. Sized to accommodate a large
/// workspace's worth of genuinely-missing references without unbounded
/// growth. On overflow the cache is cleared; the cost is a few extra
/// resolver calls until it re-fills.
const UNRESOLVABLE_CACHE_CAP: usize = 10_000;

impl AnalysisSession {
    /// Create a session targeting the given PHP language version.
    pub fn new(php_version: PhpVersion) -> Self {
        let db = Arc::new(AnalyzerDb::new());
        db.salsa
            .write()
            .set_php_version(Arc::from(php_version.to_string().as_str()));
        Self {
            db,
            cache: None,
            psr4: None,
            resolver: None,
            php_version,
            user_stub_files: Vec::new(),
            user_stub_dirs: Vec::new(),
            stale_defined_symbols: Arc::new(RwLock::new(HashMap::default())),
            unresolvable_fqcns: Arc::new(RwLock::new(HashMap::default())),
            source_provider: Arc::new(crate::FsSourceProvider),
        }
    }

    /// Swap in a custom [`crate::SourceProvider`]. LSPs install a VFS-backed
    /// provider here so the analyzer reads from unsaved editor buffers
    /// instead of disk.
    pub fn with_source_provider(mut self, provider: Arc<dyn crate::SourceProvider>) -> Self {
        self.source_provider = provider;
        self
    }

    /// Attach a pre-built [`AnalysisCache`] (the body-analysis issue cache) and
    /// open a sibling definition [`StubSlice`] cache under the same root, so
    /// callers using this builder get the same speedup as `with_cache_dir`.
    ///
    /// Rebuilds the shared database to attach the definition cache — call
    /// **before** any file is ingested. A debug assertion catches misuse.
    ///
    /// [`StubSlice`]: mir_codebase::storage::StubSlice
    pub fn with_cache(mut self, cache: Arc<AnalysisCache>) -> Self {
        debug_assert_eq!(
            self.db.source_file_count(),
            0,
            "AnalysisSession::with_cache must be called before any file is ingested"
        );
        let dir = cache.cache_dir().to_path_buf();
        self.db = Arc::new(AnalyzerDb::new().with_cache_dir(&dir));
        self.db
            .salsa
            .write()
            .set_php_version(Arc::from(self.php_version.to_string().as_str()));
        self.cache = Some(cache);
        self
    }

    /// Convenience: open a disk-backed cache at `cache_dir` and attach it.
    ///
    /// Attaches both the body-analysis issue cache ([`AnalysisCache`]) and the
    /// definition [`StubSlice`] cache to the shared database. Builds a fresh
    /// [`AnalyzerDb`] internally — call **before** any file is ingested. A
    /// debug assertion catches misuse.
    ///
    /// [`StubSlice`]: mir_codebase::storage::StubSlice
    pub fn with_cache_dir(mut self, cache_dir: &std::path::Path) -> Self {
        debug_assert_eq!(
            self.db.source_file_count(),
            0,
            "AnalysisSession::with_cache_dir must be called before any file is ingested"
        );
        self.db = Arc::new(AnalyzerDb::new().with_cache_dir(cache_dir));
        self.db
            .salsa
            .write()
            .set_php_version(Arc::from(self.php_version.to_string().as_str()));
        self.cache = Some(Arc::new(AnalysisCache::open(cache_dir)));
        self
    }

    /// Attach a Composer autoload map (PSR-4, PSR-0, classmap, files).
    /// Sets the same map as the active [`crate::ClassResolver`] so
    /// [`Self::load_class`] works out of the box.
    pub fn with_psr4(mut self, map: Arc<Psr4Map>) -> Self {
        let user_resolver: Arc<dyn crate::ClassResolver> = map.clone();
        // Wrap with stub awareness so `find_class_like` / `resolve_fqcn_to_path`
        // can map built-in PHP class FQCNs (`ArrayObject`, `Exception`, …)
        // to their stub virtual paths.
        let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
            user_resolver,
            Arc::new(crate::StubClassResolver),
        ));
        self.psr4 = Some(map);
        self.resolver = Some(resolver.clone());
        // Mirror into MirDbStorage so salsa-tracked resolver queries
        // (`db::resolve_fqcn_to_path`) see the same resolver and are
        // invalidated on swap.
        self.db.salsa.write().set_resolver(Some(resolver));
        self
    }

    /// Attach a generic class resolver for projects that don't use Composer
    /// (WordPress, Drupal, custom autoloaders, workspace-walk indexes).
    /// Replaces any previously-set Composer-backed resolver. Automatically
    /// wrapped with stub awareness so PHP built-ins remain resolvable.
    pub fn with_class_resolver(mut self, resolver: Arc<dyn crate::ClassResolver>) -> Self {
        let wrapped: Arc<dyn crate::ClassResolver> = Arc::new(crate::ChainedClassResolver::new(
            resolver,
            Arc::new(crate::StubClassResolver),
        ));
        self.db.salsa.write().set_resolver(Some(wrapped.clone()));
        self.resolver = Some(wrapped);
        self
    }

    pub fn with_user_stubs(mut self, files: Vec<PathBuf>, dirs: Vec<PathBuf>) -> Self {
        self.user_stub_files = files;
        self.user_stub_dirs = dirs;
        self
    }

    pub fn php_version(&self) -> PhpVersion {
        self.php_version
    }

    pub fn cache(&self) -> Option<&AnalysisCache> {
        self.cache.as_deref()
    }

    pub fn psr4(&self) -> Option<&Psr4Map> {
        self.psr4.as_deref()
    }

    /// Deprecated — stub loading is now fully lazy per-AST.
    ///
    /// This is an alias for [`Self::ensure_all_stubs`] kept for API
    /// compatibility. Internal analysis paths use [`Self::prepare_ast_for_analysis`]
    /// which loads only the stubs referenced by the file under analysis.
    #[deprecated(note = "use ensure_all_stubs() or ensure_stubs_for_ast() instead")]
    pub fn ensure_essential_stubs(&self) {
        self.ensure_all_stubs();
    }

    /// Load every embedded PHP stub plus any configured user stubs.
    /// Use for batch tools (CLI, full project analysis) where comprehensive
    /// symbol coverage matters more than cold-start latency.
    pub fn ensure_all_stubs(&self) {
        let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
        self.db.ingest_stub_paths(&paths, self.php_version);
        self.ensure_user_stubs_loaded();
    }

    /// Ensure the embedded stub that defines `name` (a function) is ingested.
    /// Returns `true` when a matching stub exists (whether or not it was
    /// already loaded), `false` when `name` isn't a known PHP built-in.
    ///
    /// Most callers should use [`Self::ensure_stubs_for_ast`] instead —
    /// it auto-discovers needed stubs from a parsed file.
    #[doc(hidden)]
    pub fn ensure_stub_for_function(&self, name: &str) -> bool {
        match crate::stubs::stub_path_for_function(name) {
            Some(path) => {
                self.db.ingest_stub_paths(&[path], self.php_version);
                true
            }
            None => false,
        }
    }

    /// Ensure the embedded stub that defines `fqcn` (a class / interface /
    /// trait / enum) is ingested. Case-insensitive lookup with optional
    /// leading backslash.
    ///
    /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
    #[doc(hidden)]
    pub fn ensure_stub_for_class(&self, fqcn: &str) -> bool {
        match crate::stubs::stub_path_for_class(fqcn) {
            Some(path) => {
                self.db.ingest_stub_paths(&[path], self.php_version);
                true
            }
            None => false,
        }
    }

    /// Ensure the embedded stub that defines `name` (a constant) is ingested.
    ///
    /// Most callers should use [`Self::ensure_stubs_for_ast`] instead.
    #[doc(hidden)]
    pub fn ensure_stub_for_constant(&self, name: &str) -> bool {
        match crate::stubs::stub_path_for_constant(name) {
            Some(path) => {
                self.db.ingest_stub_paths(&[path], self.php_version);
                true
            }
            None => false,
        }
    }

    /// Number of distinct embedded stubs currently ingested into the session.
    /// Useful for diagnostics and bench reporting.
    pub fn loaded_stub_count(&self) -> usize {
        self.db.loaded_stubs.lock().len()
    }

    /// Auto-discover and ingest the embedded stubs needed to cover every
    /// built-in PHP function / class / constant referenced by `source`.
    ///
    /// Used by [`crate::FileAnalyzer::analyze`] to keep essentials-only mode
    /// correct without forcing callers to enumerate which stubs they need.
    /// Idempotent — already-loaded stubs are skipped via [`Self::loaded_stubs`].
    ///
    /// The discovery scan is a coarse identifier sweep (see
    /// [`crate::stubs::collect_referenced_builtin_paths`]) — it may pull in
    /// a slightly larger set than the file strictly needs, but never misses
    /// a referenced built-in. Cost is sub-millisecond per file.
    ///
    /// Fast path: if every embedded stub is already loaded (e.g. after a
    /// batch tool called [`Self::ensure_all_stubs`]), the source scan
    /// is skipped entirely.
    pub fn ensure_stubs_for_source(&self, source: &str) {
        // Cheap check first: skip the scan entirely when we already know we
        // have everything. Avoids a ~50-500µs source walk on every analyze
        // call in batch / warm-session scenarios.
        {
            let loaded = self.db.loaded_stubs.lock();
            if loaded.len() >= crate::stubs::stub_files().len() {
                return;
            }
        }
        let paths = crate::stubs::collect_referenced_builtin_paths(source);
        if paths.is_empty() {
            return;
        }
        self.db.ingest_stub_paths(&paths, self.php_version);
    }

    /// Discover and ingest stubs by walking the parsed AST of a PHP file.
    ///
    /// Similar to [`Self::ensure_stubs_for_source`], but takes an already-parsed
    /// AST instead of raw source text. Produces zero false positives since it
    /// only extracts identifiers from actual AST nodes (not from strings or
    /// comments). Preferred over `ensure_stubs_for_source` when the AST is
    /// already available (e.g., in [`crate::FileAnalyzer`]).
    ///
    /// Idempotent and skips the scan if all stubs are already loaded.
    pub fn ensure_stubs_for_ast(&self, program: &php_ast::owned::Program) {
        {
            let loaded = self.db.loaded_stubs.lock();
            if loaded.len() >= crate::stubs::stub_files().len() {
                return;
            }
        }
        let paths = crate::stubs::collect_referenced_builtin_paths_from_ast(program);
        if paths.is_empty() {
            return;
        }
        self.db.ingest_stub_paths(&paths, self.php_version);
    }

    /// Returns true if this session has a configured class resolver
    /// (typically a PSR-4 / classmap autoloader chained with the stub
    /// resolver). Used by `FileAnalyzer` to skip the AST-scan preload
    /// when no resolver is wired up.
    pub fn has_resolver(&self) -> bool {
        self.resolver.is_some()
    }

    /// Run both pre-passes (builtin-stub loading and PSR-4 class preloading)
    /// in one call.  Replaces the two separate `ensure_stubs_for_ast` /
    /// `preload_psr4_classes_for_ast` calls at every `FileAnalyzer::analyze`
    /// site.
    pub fn prepare_ast_for_analysis(&self, program: &php_ast::owned::Program, file: &str) {
        self.ensure_stubs_for_ast(program);
        self.priority_index_for_ast(program, file);
    }

    /// Priority-index the classes directly referenced by `file`'s AST.
    ///
    /// In the eager-static-input model the background indexer
    /// ([`Self::index_batch`]) walks the whole vendor tree, but it may not have
    /// reached every file the open buffer references yet. To avoid a transient
    /// false `UndefinedClass` during the warm-up window, this **reorders** that
    /// static work: it resolves the buffer's *direct* class references and
    /// loads any not-yet-indexed ones immediately, jumping them to the front of
    /// the queue.
    ///
    /// This is bounded by the number of distinct direct references in **one**
    /// file — no transitive BFS, no depth/total budget, no pinning. Inheritance
    /// ancestors and signature types of those classes are picked up by the
    /// background walk (or, for navigation, by [`Self::hover`] /
    /// [`Self::definition_of`]). Because `bump_workspace_revision` no longer
    /// nulls the workspace index singleton, each [`Self::load_class`] here costs
    /// only a resolver lookup + parse (or cache hit) + one tier-aware merge,
    /// invalidating just the actively-analyzed file's memo once — not the whole
    /// cache. Once background indexing completes this is a no-op (every
    /// reference already resolves).
    pub fn priority_index_for_ast(&self, program: &php_ast::owned::Program, file: &str) {
        if self.resolver.is_none() {
            return;
        }
        let refs = collect_class_refs_from_ast(program);
        if refs.is_empty() {
            return;
        }
        // Resolve names against the file's namespace/imports up front, then
        // drop the snapshot before loading (which mutates inputs).
        let resolved: Vec<String> = {
            let db = self.snapshot_db();
            refs.into_iter()
                .map(|raw| crate::db::resolve_name(&db, file, &raw))
                .collect()
        };
        for fqcn in resolved {
            // load_class is a no-op when the class is already indexed (the
            // common case once the background walk has passed this file).
            self.load_class(&fqcn);
        }
    }

    fn ensure_user_stubs_loaded(&self) {
        self.db
            .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
    }

    /// Cheap clone of the salsa db for a read-only query. The lock is held
    /// only for the duration of the clone, so concurrent readers never
    /// serialize on each other or on writes for longer than the clone itself.
    ///
    /// **Internal API — exposes Salsa types.** Subject to change without
    /// notice. Public consumers should use the typed query methods
    /// ([`Self::definition_of`], [`Self::hover`], etc.) instead.
    #[doc(hidden)]
    pub fn snapshot_db(&self) -> MirDbStorage {
        self.db.snapshot_db()
    }

    /// Commit a batch of reference locations from a db snapshot into the
    /// session's shared maps.  Called by [`crate::FileAnalyzer`] and
    /// [`crate::BatchFileAnalyzer`] after parallel body analysis to flush the pending
    /// buffers that accumulate in worker db clones.
    pub(crate) fn commit_ref_locs_batch(&self, locs: Vec<RefLoc>) {
        if locs.is_empty() {
            return;
        }
        let guard = self.db.salsa.read();
        guard.commit_reference_locations_batch(locs);
    }

    /// Run a closure with read access to a database snapshot.
    ///
    /// **Internal API — exposes Salsa types.** Subject to change without
    /// notice.
    #[doc(hidden)]
    pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> R {
        let db = self.snapshot_db();
        f(&db)
    }

    /// definition-collection ingestion. Updates the file's source text in the salsa db,
    /// runs definition collection, and ingests the resulting stub slice.
    /// Triggers stub loading on first call. Also updates the cache's reverse-
    /// dependency graph for `file` so cross-file invalidation stays correct
    /// across incremental edits — without rebuilding the graph from scratch.
    ///
    /// If `file` was previously ingested, its old definitions and reference
    /// locations are removed first so renames / deletions don't leave stale
    /// state in the codebase. (Without this, long-running sessions would
    /// accumulate dead reference-location entries indefinitely.)
    pub fn ingest_file(&self, file: Arc<str>, source: Arc<str>) {
        self.ensure_all_stubs();

        // Snapshot symbols defined before clearing — O(symbols_in_file) with forward index.
        let old_symbols: HashSet<Arc<str>> = {
            let guard = self.db.salsa.read();
            guard.file_defined_symbols(file.as_ref())
        };

        {
            let mut guard = self.db.salsa.write();
            guard.remove_file_definitions(file.as_ref());
        }
        let _file_defs =
            self.db
                .collect_and_ingest_file(file.clone(), source.as_ref(), self.php_version);

        // Snapshot symbols after ingesting — O(symbols_in_file).
        let new_symbols: HashSet<Arc<str>> = {
            let guard = self.db.salsa.read();
            guard.file_defined_symbols(file.as_ref())
        };

        // Symbols removed from this file must be tracked so dependency_graph()
        // can still produce edges to files referencing the now-gone symbols.
        let deleted: Vec<Arc<str>> = old_symbols.difference(&new_symbols).cloned().collect();
        let re_added: Vec<Arc<str>> = new_symbols.difference(&old_symbols).cloned().collect();
        if !deleted.is_empty() || !re_added.is_empty() {
            let mut stale = self.stale_defined_symbols.write();
            let entry = stale.entry(file.as_ref().to_string()).or_default();
            for sym in deleted {
                entry.insert(sym);
            }
            for sym in &re_added {
                entry.remove(sym);
            }
            if entry.is_empty() {
                stale.remove(file.as_ref());
            }
        }

        self.update_reverse_deps_for(&file);
        // Evict cached analysis results for files that depend on this one so
        // that the next re_analyze_file call re-analyses them rather than
        // replaying a stale cache entry. Mirrors the eviction in
        // `re_analyze_file` (batch.rs) but applies to the ingest path used by
        // LSP servers that edit a single file without re-analysing it.
        if let Some(cache) = self.cache.as_deref() {
            cache.evict_with_dependents(&[file.to_string()]);
        }
        // Only evict cache entries whose resolver-mapped path equals this
        // file. FQCNs the resolver can't map (psr4 miss) stay cached — no
        // ingest could change their fate. Avoids the per-keystroke storm
        // where wholesale clearing forces every unresolved FQCN to re-hit
        // the resolver on the next FileAnalyzer iteration.
        self.evict_unresolvable_for_file(&file);

        // If the workspace symbol index singleton has already been built, keep
        // it consistent with this edit *incrementally*: subtract the file's old
        // declarations and add its new ones (tier-aware). Body-only edits are a
        // no-op inside `update_workspace_index_for_file` (name-only
        // FileDeclarations equality → no singleton write → the HIGH-durability
        // dep does not invalidate body-analysis memos). Only the rare ambiguous
        // case (a removed name still declared by another file, where this file
        // owned the winning entry) falls back to a full O(N) rebuild.
        {
            let mut guard = self.db.salsa.write();
            if guard.workspace_symbol_index_singleton().is_some() {
                if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
                    if !guard.update_workspace_index_for_file(sf) {
                        guard.rebuild_workspace_symbol_index();
                    }
                }
            }
        }
    }

    /// Register `source` as the text of `file` in the salsa input layer **without**
    /// parsing or running definition collection.
    ///
    /// This is the LSP-friendly bulk-population entry point: after a workspace
    /// scan, callers can feed every discovered file's text to the session
    /// cheaply (an Arc clone plus a HashMap insert per file). Name resolution
    /// then happens on demand via [`Self::load_class`], which reads
    /// the file from disk through the configured [`crate::ClassResolver`] and
    /// runs definition collection lazily when a class FQCN actually needs to resolve.
    ///
    /// Contrast with [`Self::ingest_file`], which eagerly parses, runs definition collection,
    /// and populates the symbol index. Use `ingest_file` for files the user is
    /// actively editing (where in-memory text diverges from disk); use
    /// `set_file_text` for files known only through the workspace scan.
    ///
    /// Clears the negative cache: a previously-unresolvable FQCN may now
    /// resolve if its defining file is among the newly-registered set.
    pub fn set_file_text(&self, file: Arc<str>, source: Arc<str>) {
        {
            let mut guard = self.db.salsa.write();
            guard.upsert_source_file(file.clone(), source);
        }
        self.evict_unresolvable_for_file(&file);
    }

    /// Bulk-register vendor / library files with HIGH salsa durability.
    ///
    /// HIGH-durability files are not expected to change during the session.
    /// When a LOW-durability project file is edited, salsa can skip O(N)
    /// dependency verification for every HIGH-durability file, reducing
    /// `workspace_symbol_index` re-verification cost to O(project files only).
    ///
    /// Definition collection runs lazily on first symbol access; no parsing at call time.
    pub fn set_vendor_files<I>(&self, files: I)
    where
        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
    {
        let mut guard = self.db.salsa.write();
        for (file, source) in files {
            guard.upsert_source_file_with_durability(file, source, salsa::Durability::HIGH);
        }
    }

    /// Build or refresh the `WorkspaceSymbolIndexSingleton` from all currently
    /// registered files.
    ///
    /// After this call, `find_class_like`, `find_function`, and
    /// `find_global_constant` read `singleton.index(db)` — a single
    /// `Durability::HIGH` tracked dep — instead of recomputing the full
    /// O(N_files) dep list via `workspace_symbol_index`. On subsequent
    /// LOW-durability (project-file) body edits the dep short-circuits in O(1).
    ///
    /// Call this once after all vendor + stub + project files have been
    /// ingested (end of workspace warm-up). Also called automatically by
    /// [`Self::ingest_file`] when a file's declared names change.
    pub fn rebuild_workspace_symbol_index(&self) {
        self.db.salsa.write().rebuild_workspace_symbol_index();
    }

    /// Bulk variant of [`Self::set_file_text`]. Acquires the salsa write lock
    /// once for the entire batch instead of once per file.
    ///
    /// The intended LSP scan loop is:
    /// ```text
    /// let files: Vec<_> = walk_workspace()
    ///     .map(|path| (path, fs::read(&path).unwrap()))
    ///     .collect();
    /// session.set_workspace_files(files);
    /// ```
    /// After this call, every file's source text is known to salsa. No
    /// parsing has happened yet — Definition collection runs per file on the first
    /// `load_class` that needs to consult it.
    pub fn set_workspace_files<I>(&self, files: I)
    where
        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
    {
        let registered_paths: Vec<Arc<str>> = {
            let mut guard = self.db.salsa.write();
            files
                .into_iter()
                .map(|(file, source)| {
                    guard.upsert_source_file(file.clone(), source);
                    file
                })
                .collect()
        };
        if !registered_paths.is_empty() && self.resolver.is_some() {
            self.evict_unresolvable_for_files(&registered_paths);
        }
    }

    /// The workspace generation epoch — the rust-analyzer-style "are we up to
    /// date" counter. Bumped whenever a file is added or removed. A consumer
    /// records this alongside the diagnostics it publishes for a file; when the
    /// value later advances (background indexing registered more files), those
    /// files become candidates for re-analysis + re-publish.
    pub fn index_generation(&self) -> u64 {
        self.db.salsa.read().workspace_revision_value()
    }

    /// Index one bounded chunk of `(path, text)` files — the chunked background
    /// indexing primitive.
    ///
    /// For each chunk this: (1) registers the files as `Durability::HIGH` salsa
    /// inputs in one short write window, (2) parses them to prime the in-process
    /// and on-disk declaration caches (in parallel when `parallelism ==
    /// `[`IndexParallelism::Rayon`]; sequentially for wasm / single-thread
    /// consumers), and (3) merges their declarations into the workspace symbol
    /// index singleton **incrementally** (no full rebuild) so partially-indexed
    /// symbols resolve immediately.
    ///
    /// The library spawns no thread: the consumer pumps chunks from its own
    /// driver (LSP worker thread, or one chunk per wasm event-loop tick),
    /// re-checking higher-priority work between calls. `cancel` is honoured at
    /// chunk boundaries so an edit can abandon queued indexing cheaply.
    ///
    /// **Contract:** index the workspace *incrementally* through this method;
    /// don't bulk-register the entire file set up front and then index — the
    /// first call lazily seeds the singleton from the currently-registered set
    /// (built-in stubs + this chunk), so keeping that initial set small keeps
    /// the first call cheap. Call [`Self::finalize_index`] once after the last
    /// chunk to reconcile authoritatively.
    ///
    /// **Responsiveness:** parsing / declaration collection happens off the
    /// salsa write lock (on a snapshot); only the cheap symbol-map merge runs
    /// under the lock, so the write window per chunk is short and an interactive
    /// read on another thread blocks at most that long. Note that, per salsa's
    /// snapshot model, a *cancellable query* in flight on another thread (e.g.
    /// `hover`, `definition_of`, `FileAnalyzer::analyze`) when this batch takes
    /// the write lock may unwind with `salsa::Cancelled`; a multi-threaded
    /// consumer should catch that and retry the request (the rust-analyzer
    /// pattern). A single-threaded consumer that interleaves requests *between*
    /// `index_batch` calls never observes cancellation.
    pub fn index_batch(
        &self,
        files: &[(Arc<str>, Arc<str>)],
        parallelism: crate::IndexParallelism,
        cancel: &crate::IndexCancel,
    ) -> crate::IndexBatchOutcome {
        if files.is_empty() || cancel.is_cancelled() {
            return crate::IndexBatchOutcome {
                registered: 0,
                cancelled: cancel.is_cancelled(),
                generation: self.index_generation(),
            };
        }
        self.ensure_all_stubs();

        // 1. Register the chunk as HIGH-durability inputs — one short write
        //    window, then release the lock so interactive requests interleave.
        let sources: Vec<crate::db::SourceFile> = {
            let mut guard = self.db.salsa.write();
            files
                .iter()
                .map(|(file, source)| {
                    guard.upsert_source_file_with_durability(
                        file.clone(),
                        source.clone(),
                        salsa::Durability::HIGH,
                    )
                })
                .collect()
        };
        let registered = sources.len();

        if cancel.is_cancelled() {
            return crate::IndexBatchOutcome {
                registered,
                cancelled: true,
                generation: self.index_generation(),
            };
        }

        // Is this the seed chunk (no singleton yet)? If so we must collect decls
        // for the whole currently-registered set (stubs + this chunk); otherwise
        // just this chunk.
        let seed = self
            .db
            .salsa
            .read()
            .workspace_symbol_index_singleton()
            .is_none();
        let snap = self.db.snapshot_db();
        let to_collect: Vec<crate::db::SourceFile> = if seed {
            snap.all_source_files()
        } else {
            sources.clone()
        };

        // 2. Collect per-file declarations OFF the write lock (on a snapshot).
        //    This is where parsing happens — crucially NOT while holding the
        //    write lock, so concurrent interactive reads are not blocked for the
        //    parse duration. Also primes the shared parse/disk caches.
        let collect_one = |db: &crate::db::MirDbStorage, sf: crate::db::SourceFile| {
            (sf, crate::db::collect_file_declarations(db, sf))
        };
        let decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> =
            if parallelism == crate::IndexParallelism::Rayon {
                use rayon::prelude::*;
                to_collect
                    .par_iter()
                    .map_with(snap.clone(), |db, &sf| collect_one(db, sf))
                    .collect()
            } else {
                to_collect
                    .iter()
                    .map(|&sf| collect_one(&snap, sf))
                    .collect()
            };
        drop(snap);

        if cancel.is_cancelled() {
            return crate::IndexBatchOutcome {
                registered,
                cancelled: true,
                generation: self.index_generation(),
            };
        }

        // 3. Apply to the singleton under a SHORT write window — only cheap map
        //    construction / merge runs here (no parse).
        {
            let mut guard = self.db.salsa.write();
            if guard.workspace_symbol_index_singleton().is_none() {
                guard.build_workspace_index_from_decls(decls);
            } else {
                guard.merge_precomputed_into_workspace_index(&decls);
            }
        }

        crate::IndexBatchOutcome {
            registered,
            cancelled: cancel.is_cancelled(),
            generation: self.index_generation(),
        }
    }

    /// Authoritative full rebuild of the workspace symbol index. Call once
    /// after the consumer has pumped every [`Self::index_batch`] chunk (end of
    /// warm-up) to reconcile the incrementally-merged index against the full
    /// registered set. Cheap after indexing — every file's declarations are
    /// already cached.
    pub fn finalize_index(&self) {
        self.db.salsa.write().rebuild_workspace_symbol_index();
    }

    /// Drop a file's contribution to the session: codebase definitions,
    /// reference locations, salsa input handle, cache entry, and outgoing
    /// reverse-dependency edges. Cache entries of *dependent* files are
    /// also evicted (cross-file invalidation).
    ///
    /// Use this when a file is closed by the consumer, or before a re-ingest
    /// of substantially changed content. (Plain re-ingest via
    /// [`Self::ingest_file`] also drops old definitions, but does not
    /// remove the salsa input handle — call this for full cleanup.)
    pub fn invalidate_file(&self, file: &str) {
        {
            let mut guard = self.db.salsa.write();
            guard.remove_file_definitions(file);
            guard.remove_source_file(file);
        }
        // Outgoing structural edges disappear from the derived graph
        // automatically: the file is no longer in `source_file_paths()`, so
        // `dependency_graph()` stops iterating it.
        // Clear stale symbol tracking for this file — it's fully gone.
        self.stale_defined_symbols.write().remove(file);
        if let Some(cache) = &self.cache {
            cache.update_reverse_deps_for_file(file, &HashSet::default());
            cache.evict_with_dependents(&[file.to_string()]);
        }
        // The file is gone; cache entries that previously mapped to it stay
        // unresolvable until the file (or another with matching symbols) is
        // ingested again. Selective evict mirrors the ingest path.
        self.evict_unresolvable_for_file(file);
        // Vendor files are static in the eager-index model — closing a project
        // buffer never evicts them (no per-file pinning). Memory is bounded by
        // the LRU on `collect_file_definitions` and the parse cache instead.
    }

    /// Number of files currently tracked in this session's salsa input set.
    /// Stable across reads; useful for diagnostics and memory bounds checks.
    pub fn tracked_file_count(&self) -> usize {
        let guard = self.db.salsa.read();
        guard.source_file_count()
    }

    // -----------------------------------------------------------------------
    // Read-only codebase queries
    //
    // All take a brief lock to clone the db, then run the lookup against the
    // owned snapshot — concurrent edits proceed without blocking.
    // -----------------------------------------------------------------------

    /// Resolve a top-level symbol (class or function) to its declaration
    /// location. Powers go-to-definition.
    ///
    /// **Side effects:** if the symbol isn't yet known, this may invoke the
    /// configured [`crate::SourceProvider`] to fault in additional files and
    /// mutate the salsa input set. Use [`Self::definition_of_cached`] for a
    /// pure variant that only consults already-loaded state.
    ///
    /// Returns:
    /// - `Ok(Location)` — symbol found with a source location
    /// - `Err(NotFound)` — no such symbol in the codebase
    /// - `Err(NoSourceLocation)` — symbol exists but has no recorded span
    ///   (e.g. some stub-only declarations)
    pub fn definition_of(
        &self,
        symbol: &crate::Name,
    ) -> Result<mir_types::Location, crate::SymbolLookupError> {
        // Trigger any necessary lazy-load mutations before snapshotting.
        match symbol {
            crate::Name::Class(fqcn) => {
                let _ = self.load_class(fqcn.as_ref());
            }
            crate::Name::Function(fqn) => {
                let _ = self.load_class(fqn.as_ref());
            }
            crate::Name::Method { class, .. }
            | crate::Name::Property { class, .. }
            | crate::Name::ClassConstant { class, .. } => {
                let _ = self.load_class(class.as_ref());
            }
            _ => {}
        }
        self.definition_of_cached(symbol)
    }

    /// Pure variant of [`Self::definition_of`]. Never invokes the
    /// [`crate::SourceProvider`] and never mutates salsa inputs; resolves
    /// only against state already loaded by `set_file_text` / `ingest_file`.
    /// Returns `Err(NotFound)` when the symbol isn't in the loaded set, even
    /// if a resolver could in principle map it.
    pub fn definition_of_cached(
        &self,
        symbol: &crate::Name,
    ) -> Result<mir_types::Location, crate::SymbolLookupError> {
        let db = self.snapshot_db();
        match symbol {
            crate::Name::Class(fqcn) => {
                let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
                let class = crate::db::find_class_like(&db, here)
                    .ok_or(crate::SymbolLookupError::NotFound)?;
                class
                    .location()
                    .cloned()
                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
            }
            crate::Name::Function(fqn) => {
                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
                let f = crate::db::find_function(&db, here)
                    .ok_or(crate::SymbolLookupError::NotFound)?;
                f.location
                    .clone()
                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
            }
            crate::Name::Method { class, name }
            | crate::Name::Property { class, name }
            | crate::Name::ClassConstant { class, name } => {
                crate::db::member_location(&db, class, name)
                    .ok_or(crate::SymbolLookupError::NotFound)
            }
            crate::Name::GlobalConstant(_) => Err(crate::SymbolLookupError::NoSourceLocation),
        }
    }

    /// Hover information for a symbol: type, docstring, and definition location.
    ///
    /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor
    /// position, then build a [`crate::Name`] from its `kind`. This method
    /// assembles the displayable hover data.
    ///
    /// **Side effects:** when `symbol`'s owning class isn't yet loaded, this
    /// may invoke the configured [`crate::SourceProvider`] to fault in
    /// dependencies. Use [`Self::hover_cached`] for a pure variant.
    ///
    /// Returns `Err(NotFound)` if the symbol doesn't exist. May still return
    /// `Ok` with `docstring: None` or `definition: None` if those specific
    /// pieces aren't available.
    pub fn hover(
        &self,
        symbol: &crate::Name,
    ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
        // Trigger lazy loading for class-rooted symbols before snapshotting.
        // No-op when the class is already known; ensures inherited member
        // lookups have the chain present.
        match symbol {
            crate::Name::Class(fqcn) => {
                self.load_class(fqcn.as_ref());
            }
            crate::Name::Method { class, .. }
            | crate::Name::Property { class, .. }
            | crate::Name::ClassConstant { class, .. } => {
                // Fault in the owning class for navigation if the background
                // indexer hasn't reached it yet. Its inheritance ancestors
                // resolve through the (eagerly-built) workspace symbol index.
                self.load_class(class.as_ref());
            }
            _ => {}
        }
        self.hover_cached(symbol)
    }

    /// Pure variant of [`Self::hover`]. Never invokes the
    /// [`crate::SourceProvider`]; consults only the already-loaded db.
    pub fn hover_cached(
        &self,
        symbol: &crate::Name,
    ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
        use mir_types::{Atomic, Type};
        let db = self.snapshot_db();
        match symbol {
            crate::Name::Function(fqn) => {
                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
                let f = crate::db::find_function(&db, here)
                    .ok_or(crate::SymbolLookupError::NotFound)?;
                let ty = f
                    .return_type
                    .as_deref()
                    .cloned()
                    .unwrap_or_else(Type::mixed);
                let docstring = f.docstring.as_ref().map(|s| s.to_string());
                Ok(crate::HoverInfo {
                    ty,
                    docstring,
                    definition: f.location.clone(),
                })
            }
            crate::Name::Method { class, name } => {
                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
                let (_, m) = crate::db::find_method_in_chain(&db, here, name)
                    .ok_or(crate::SymbolLookupError::NotFound)?;
                let ty = m
                    .return_type
                    .as_deref()
                    .cloned()
                    .unwrap_or_else(Type::mixed);
                let docstring = m.docstring.as_ref().map(|s| s.to_string());
                Ok(crate::HoverInfo {
                    ty,
                    docstring,
                    definition: m.location.clone(),
                })
            }
            crate::Name::Class(fqcn) => {
                let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
                let class = crate::db::find_class_like(&db, here)
                    .ok_or(crate::SymbolLookupError::NotFound)?;
                let ty = Type::single(Atomic::TNamedObject {
                    fqcn: mir_types::Name::from(fqcn.as_ref()),
                    type_params: mir_types::union::empty_type_params(),
                });
                Ok(crate::HoverInfo {
                    ty,
                    docstring: None,
                    definition: class.location().cloned(),
                })
            }
            crate::Name::Property { class, name } => {
                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
                let (_, p) = crate::db::find_property_in_chain(&db, here, name)
                    .ok_or(crate::SymbolLookupError::NotFound)?;
                let ty = p.ty.as_deref().cloned().unwrap_or_else(Type::mixed);
                Ok(crate::HoverInfo {
                    ty,
                    docstring: None,
                    definition: p.location.clone(),
                })
            }
            crate::Name::ClassConstant { class, name } => {
                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
                let (_, c) = crate::db::find_class_constant_in_chain(&db, here, name)
                    .ok_or(crate::SymbolLookupError::NotFound)?;
                Ok(crate::HoverInfo {
                    ty: c.ty.clone(),
                    docstring: None,
                    definition: c.location.clone(),
                })
            }
            crate::Name::GlobalConstant(fqn) => {
                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
                let ty = crate::db::find_global_constant(&db, here)
                    .ok_or(crate::SymbolLookupError::NotFound)?;
                Ok(crate::HoverInfo {
                    ty: (*ty).clone(),
                    docstring: None,
                    definition: None,
                })
            }
        }
    }

    /// Raw reference locations indexed by string symbol key, kept for tests
    /// that use the legacy stringly-typed API. Prefer [`Self::references_to`]
    /// with a typed [`crate::Name`].
    #[doc(hidden)]
    pub fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
        use crate::db::MirDatabase;
        let db = self.snapshot_db();
        db.reference_locations(symbol)
    }

    /// Every recorded reference to `symbol` with its source location as a Range.
    /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor,
    /// build a [`crate::Name`] from it, and pass it here.
    pub fn references_to(&self, symbol: &crate::Name) -> Vec<(Arc<str>, crate::Range)> {
        let db = self.snapshot_db();
        let key = symbol.codebase_key();
        db.reference_locations(&key)
            .into_iter()
            .map(|(file, line, col_start, col_end)| {
                let range = crate::Range {
                    start: crate::Position {
                        line,
                        column: col_start as u32,
                    },
                    end: crate::Position {
                        line,
                        column: col_end as u32,
                    },
                };
                (file, range)
            })
            .collect()
    }

    /// Class-level issues (inheritance violations, abstract-method gaps, override
    /// incompatibilities) for the given set of files.
    ///
    /// These checks are cross-file by nature and are not emitted by
    /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
    /// re-analyzing a file and its dependents to get the full diagnostic picture.
    ///
    /// Circular-inheritance checks always run against the full workspace graph
    /// regardless of the `files` filter — a cycle is a workspace-wide problem.
    pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
        let db = self.snapshot_db();
        let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
        let file_data: Vec<(Arc<str>, Arc<str>)> = files
            .iter()
            .filter_map(|f| Some((f.clone(), self.source_of(f)?)))
            .collect();
        crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
    }

    /// All declarations defined in `file` as a **hierarchical tree**.
    ///
    /// Classes/interfaces/traits/enums are returned with their methods,
    /// properties, and constants nested in `children`. Top-level functions
    /// and constants are returned with empty `children`.
    pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
        use crate::symbol::{DeclarationKind, DocumentSymbol};

        let db = self.snapshot_db();
        let Some(sf) = db.lookup_source_file(file) else {
            return Vec::new();
        };
        let defs = crate::db::collect_file_definitions(&db, sf);
        let mut out: Vec<DocumentSymbol> = Vec::new();

        let class_children =
            |methods: &indexmap::IndexMap<Arc<str>, Arc<mir_codebase::storage::MethodDef>>,
             props: Option<&indexmap::IndexMap<Arc<str>, mir_codebase::storage::PropertyDef>>,
             consts: &indexmap::IndexMap<Arc<str>, mir_codebase::storage::ConstantDef>,
             is_enum: bool|
             -> Vec<DocumentSymbol> {
                let mut out: Vec<DocumentSymbol> = Vec::new();
                for (_, m) in methods.iter() {
                    out.push(DocumentSymbol {
                        name: m.name.clone(),
                        kind: DeclarationKind::Method,
                        location: m.location.clone(),
                        children: Vec::new(),
                    });
                }
                if let Some(props) = props {
                    for (_, p) in props.iter() {
                        out.push(DocumentSymbol {
                            name: p.name.clone(),
                            kind: DeclarationKind::Property,
                            location: p.location.clone(),
                            children: Vec::new(),
                        });
                    }
                }
                let const_kind = if is_enum {
                    DeclarationKind::EnumCase
                } else {
                    DeclarationKind::Constant
                };
                for (_, c) in consts.iter() {
                    out.push(DocumentSymbol {
                        name: c.name.clone(),
                        kind: const_kind,
                        location: c.location.clone(),
                        children: Vec::new(),
                    });
                }
                out
            };

        for c in defs.slice.classes.iter() {
            out.push(DocumentSymbol {
                name: c.fqcn.clone(),
                kind: DeclarationKind::Class,
                location: c.location.clone(),
                children: class_children(
                    &c.own_methods,
                    Some(&c.own_properties),
                    &c.own_constants,
                    false,
                ),
            });
        }
        for i in defs.slice.interfaces.iter() {
            out.push(DocumentSymbol {
                name: i.fqcn.clone(),
                kind: DeclarationKind::Interface,
                location: i.location.clone(),
                children: class_children(&i.own_methods, None, &i.own_constants, false),
            });
        }
        for t in defs.slice.traits.iter() {
            out.push(DocumentSymbol {
                name: t.fqcn.clone(),
                kind: DeclarationKind::Trait,
                location: t.location.clone(),
                children: class_children(
                    &t.own_methods,
                    Some(&t.own_properties),
                    &t.own_constants,
                    false,
                ),
            });
        }
        for e in defs.slice.enums.iter() {
            let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
            for (_, case) in e.cases.iter() {
                children.push(DocumentSymbol {
                    name: case.name.clone(),
                    kind: DeclarationKind::EnumCase,
                    location: case.location.clone(),
                    children: Vec::new(),
                });
            }
            out.push(DocumentSymbol {
                name: e.fqcn.clone(),
                kind: DeclarationKind::Enum,
                location: e.location.clone(),
                children,
            });
        }
        for f in defs.slice.functions.iter() {
            out.push(DocumentSymbol {
                name: f.fqn.clone(),
                kind: DeclarationKind::Function,
                location: f.location.clone(),
                children: Vec::new(),
            });
        }
        for (name, _) in defs.slice.constants.iter() {
            out.push(DocumentSymbol {
                name: name.clone(),
                kind: DeclarationKind::Constant,
                location: None,
                children: Vec::new(),
            });
        }
        out
    }

    /// Returns `true` if a function with `fqn` is registered and active in
    /// the codebase. Case-insensitive lookup with optional leading backslash.
    pub fn contains_function(&self, fqn: &str) -> bool {
        let db = self.snapshot_db();
        crate::db::function_exists(&db, fqn)
    }

    /// Returns `true` if a class / interface / trait / enum with `fqcn` is
    /// registered and active in the codebase.
    pub fn contains_class(&self, fqcn: &str) -> bool {
        let db = self.snapshot_db();
        crate::db::class_exists(&db, fqcn)
    }

    /// Returns `true` if `class` has a method named `name` registered. Method
    /// names are matched case-insensitively (PHP method dispatch semantics).
    pub fn contains_method(&self, class: &str, name: &str) -> bool {
        let db = self.snapshot_db();
        crate::db::has_method_in_chain(&db, class, name)
    }

    /// Resolve `fqcn` via the configured [`crate::ClassResolver`] and ingest
    /// the mapped file. The session keeps a negative cache so repeated calls
    /// for an unresolvable name don't re-hit the resolver; the cache is
    /// invalidated on any [`Self::ingest_file`] / [`Self::invalidate_file`].
    ///
    /// This is the LSP-friendly entry point: the analyzer never touches
    /// `vendor/` on its own, but consumers can ask it to resolve individual
    /// symbols on demand. Designed to be called when a diagnostic would
    /// otherwise report `UndefinedClass`.
    ///
    /// Returns a [`crate::LoadOutcome`] distinguishing
    /// already-loaded / freshly-loaded / not-resolvable. Use
    /// [`crate::LoadOutcome::is_loaded`] when only success matters.
    pub fn load_class(&self, fqcn: &str) -> crate::LoadOutcome {
        if self.contains_class(fqcn) {
            return crate::LoadOutcome::AlreadyLoaded;
        }
        if self.unresolvable_fqcns.read().contains_key(fqcn) {
            return crate::LoadOutcome::NotResolvable;
        }
        if self.try_resolve_and_ingest(fqcn) {
            crate::LoadOutcome::Loaded
        } else {
            // Cache the failure with the resolver-mapped path (if any) so
            // future file edits can selectively evict.
            let resolved_path: Option<Arc<str>> = self
                .resolver
                .as_ref()
                .and_then(|r| r.resolve(fqcn))
                .map(|p| Arc::from(p.to_string_lossy().as_ref()));
            let key: Arc<str> = Arc::from(fqcn);
            let mut cache = self.unresolvable_fqcns.write();
            if cache.len() >= UNRESOLVABLE_CACHE_CAP {
                cache.clear();
            }
            cache.insert(key, resolved_path);
            crate::LoadOutcome::NotResolvable
        }
    }

    /// Inner load path: resolver lookup + ingest, no caching. Returns `true`
    /// iff `fqcn` ends up registered. Failure buckets are recorded for
    /// telemetry.
    fn try_resolve_and_ingest(&self, fqcn: &str) -> bool {
        use crate::metrics::{record_lazy_load_failure, LazyLoadFailure};
        let Some(resolver) = &self.resolver else {
            record_lazy_load_failure(LazyLoadFailure::NoResolver, fqcn);
            return false;
        };
        let Some(path) = resolver.resolve(fqcn) else {
            record_lazy_load_failure(LazyLoadFailure::ResolverNone, fqcn);
            return false;
        };
        let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
        // Prefer in-memory text from a prior `set_file_text` /
        // `set_workspace_files` call; fall back to disk. This makes the LSP's
        // unsaved-edit buffer authoritative over the on-disk content for the
        // same path.
        let src: Arc<str> = match self.source_of(&file) {
            Some(text) => text,
            None => match self.source_provider.read(&path.to_string_lossy()) {
                Some(text) => text,
                None => {
                    record_lazy_load_failure(LazyLoadFailure::SourceUnreadable, fqcn);
                    return false;
                }
            },
        };
        self.ingest_file(file, src);
        if self.contains_class(fqcn) {
            true
        } else {
            record_lazy_load_failure(LazyLoadFailure::IngestThenMissing, fqcn);
            false
        }
    }

    /// Evict every negative-cache entry whose stored resolver-mapped path
    /// equals `file`. FQCNs cached as never-resolvable (path `None`) are left
    /// alone — no source-text change can make them resolvable.
    fn evict_unresolvable_for_file(&self, file: &str) {
        let mut cache = self.unresolvable_fqcns.write();
        if cache.is_empty() {
            return;
        }
        cache.retain(|_fqcn, path| path.as_deref() != Some(file));
    }

    /// Bulk variant of [`Self::evict_unresolvable_for_file`]. One `HashSet`
    /// build + one pass over the cache; no resolver calls.
    fn evict_unresolvable_for_files(&self, files: &[Arc<str>]) {
        let mut cache = self.unresolvable_fqcns.write();
        if cache.is_empty() {
            return;
        }
        let registered: HashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
        cache.retain(|_fqcn, path| match path {
            Some(p) => !registered.contains(p.as_ref()),
            None => true,
        });
    }

    /// Retrieve the source text the session has registered for `file`, if
    /// any. Returns `None` when the file has never been ingested. Used by
    /// the parallel re-analysis path to re-feed dependents to body analysis without
    /// the caller having to track sources independently.
    pub fn source_of(&self, file: &str) -> Option<Arc<str>> {
        let db = self.snapshot_db();
        let sf = db.lookup_source_file(file)?;
        Some(sf.text(&db))
    }

    /// Re-analyze every transitive dependent of `file` in parallel.
    ///
    /// When the user saves a file that other files depend on (e.g. editing
    /// a base class, an interface, or a trait), those dependents may have
    /// new diagnostics. This method computes them in parallel using rayon
    /// and returns the per-file analysis results so the LSP server can
    /// publish updated diagnostics in one batch.
    ///
    /// Source text for dependents is retrieved from the session's salsa
    /// inputs (set by previous `ingest_file` calls) — the caller doesn't
    /// need to track or re-read files. Files for which the session has no
    /// source are silently skipped (returns the analyzable subset).
    ///
    /// Cross-file inferred return types are resolved on demand via salsa.
    pub fn reanalyze_dependents(&self, file: &str) -> Vec<(Arc<str>, crate::FileAnalysis)> {
        self.reanalyze_dependents_cancellable(file, &crate::IndexCancel::new())
    }

    /// Cancellable variant of [`Self::reanalyze_dependents`].
    ///
    /// The consumer flips `cancel` (typically because a newer edit arrived) to
    /// abandon the re-analysis; the flag is checked at each file boundary. Salsa
    /// cannot unwind the plain-Rust body-analysis walk mid-flight, so a file
    /// already in progress finishes, but no further files are started. Files
    /// skipped due to cancellation are simply absent from the returned vec —
    /// the consumer should drop a stale flag and start fresh work on each edit.
    pub fn reanalyze_dependents_cancellable(
        &self,
        file: &str,
        cancel: &crate::IndexCancel,
    ) -> Vec<(Arc<str>, crate::FileAnalysis)> {
        use rayon::prelude::*;

        if cancel.is_cancelled() {
            return Vec::new();
        }

        // Phase 1: compute dependents outside the analysis loop.
        let dependents = self.dependency_graph().transitive_dependents(file);
        if dependents.is_empty() {
            return Vec::new();
        }
        let dependents: Vec<Arc<str>> = dependents
            .into_iter()
            .map(|path| Arc::from(path.as_str()))
            .collect();

        // Phase 2: drive each dependent through the `analyze_file` tracked
        // query in parallel. Salsa's memo validation does the real work
        // here: after a body-only edit, a dependent whose tracked inputs are
        // structurally unchanged (`FileDefinitions` backdating) returns its
        // cached output without re-running body analysis — re-analysis cost
        // scales with what actually changed, not with dependent count.
        //
        // Dependents' `FileAnalysis::symbols` are empty on this path:
        // per-expression symbols are intentionally not memoized (a typical
        // file resolves thousands; caching them balloons memory), and
        // diagnostics consumers don't read them. Hover / go-to-definition
        // flows analyze the open file directly via [`crate::FileAnalyzer`].
        //
        // Each worker short-circuits when cancellation has been requested.
        let db_main = self.snapshot_db();
        let results: Vec<(Arc<str>, std::sync::Arc<crate::db::AnalyzeOutput>)> = dependents
            .into_par_iter()
            .map_with(db_main, |db, file| {
                if cancel.is_cancelled() {
                    return None;
                }
                let sf = db.lookup_source_file(file.as_ref())?;
                // Fault in this dependent's direct class references if the
                // background indexer hasn't reached them yet (mirrors the
                // FileAnalyzer warm-up behavior, avoiding transient false
                // UndefinedClass during index warm-up).
                let parsed = crate::db::parse_file(&*db as &dyn crate::db::MirDatabase, sf);
                self.prepare_ast_for_analysis(&parsed.0.program, file.as_ref());
                let out = crate::db::analyze_file(&*db as &dyn crate::db::MirDatabase, sf);
                Some((file, out))
            })
            .flatten()
            .collect();

        // Serial commit: each dependent's output is its complete reference
        // set, so replace rather than append.
        {
            let guard = self.db.salsa.read();
            for (file, out) in &results {
                guard.set_file_reference_locations(file.as_ref(), out.ref_locs.to_vec());
            }
        }

        results
            .into_iter()
            .map(|(file, out)| {
                (
                    file,
                    crate::FileAnalysis {
                        issues: out.issues.to_vec(),
                        symbols: Vec::new(),
                    },
                )
            })
            .collect()
    }

    /// FQCNs that `file` imports via `use` statements but that aren't yet
    /// loaded in the session.
    ///
    /// Designed as the input to background prefetching: after the LSP server
    /// ingests an open buffer, it can call this and lazy-load the returned
    /// FQCNs on a worker thread so the user's first Cmd+Click into vendor
    /// code doesn't pay the file-read+parse cost.
    ///
    /// Returns an empty Vec if the file hasn't been ingested or has no
    /// unresolved imports.
    pub fn pending_lazy_loads(&self, file: &str) -> Vec<Arc<str>> {
        let db = self.snapshot_db();
        let imports = db.file_imports(file);
        if imports.is_empty() {
            return Vec::new();
        }
        let mut out = Vec::new();
        for fqcn in imports.values() {
            let here = crate::db::Fqcn::new(&db, *fqcn);
            if crate::db::find_class_like(&db, here).is_some() {
                continue;
            }
            if let Some(resolver) = &self.resolver {
                if resolver.resolve(fqcn.as_str()).is_some() {
                    out.push(Arc::from(fqcn.as_str()));
                }
            }
        }
        out
    }

    /// Convenience: synchronously lazy-load every import of `file` that
    /// isn't already in the codebase. Returns the number successfully loaded.
    ///
    /// For non-blocking prefetch, call this from a worker thread:
    ///
    /// ```ignore
    /// let s = session.clone();  // AnalysisSession is wrapped in Arc by callers
    /// std::thread::spawn(move || {
    ///     s.prefetch_imports(&file_path);
    /// });
    /// ```
    ///
    /// Uses a single shared-visited two-tier BFS across all pending imports
    /// (see [`Self::load_classes_transitive_bounded`]) with a shallow depth so
    /// member access on imported types type-checks without pulling in the
    /// entire vendor tree.
    pub fn prefetch_imports(&self, file: &str) -> usize {
        let pending = self.pending_lazy_loads(file);
        if pending.is_empty() {
            return 0;
        }
        // Fault in each imported FQCN directly (single-file load + tier-merge).
        // Inheritance ancestors / signature types resolve through the eagerly
        // built workspace symbol index — no transitive walk needed here.
        let mut loaded = 0;
        for fqcn in &pending {
            if self.load_class(fqcn.as_ref()).is_loaded() {
                loaded += 1;
            }
        }
        loaded
    }

    /// All class / interface / trait / enum FQCNs currently known to the
    /// session, each paired with the file that defines them when available.
    ///
    /// Use this to build workspace-wide views (outline, fuzzy search, etc.).
    /// Consumers implement their own search/match logic on top — the analyzer
    /// only exposes the iterator.
    pub fn all_classes(&self) -> Vec<(Arc<str>, Option<mir_types::Location>)> {
        let db = self.snapshot_db();
        crate::db::workspace_classes(&db)
            .iter()
            .filter_map(|fqcn| {
                let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
                crate::db::find_class_like(&db, here)
                    .map(|class| (fqcn.clone(), class.location().cloned()))
            })
            .collect()
    }

    /// All global function FQNs currently known to the session, each paired
    /// with their declaration location when available.
    pub fn all_functions(&self) -> Vec<(Arc<str>, Option<mir_types::Location>)> {
        let db = self.snapshot_db();
        crate::db::workspace_functions(&db)
            .iter()
            .filter_map(|fqn| {
                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
                crate::db::find_function(&db, here).map(|f| (fqn.clone(), f.location.clone()))
            })
            .collect()
    }

    /// Compute `file`'s outgoing dependency edges and persist them to the
    /// disk cache's reverse-dep graph (if configured). The in-memory graph
    /// is no longer maintained imperatively: `dependency_graph()` derives
    /// structural edges from the memoized [`crate::db::file_structural_deps`]
    /// tracked query, so there is no second copy to drift out of sync.
    fn update_reverse_deps_for(&self, file: &str) {
        if let Some(cache) = self.cache.as_deref() {
            let db = self.snapshot_db();
            let targets = file_outgoing_dependencies(&db, file);
            cache.update_reverse_deps_for_file(file, &targets);
        }
    }

    /// File dependency graph: which files depend on which other files.
    /// Used for incremental invalidation in LSP servers and build systems.
    ///
    /// File dependency graph: which files depend on which other files.
    /// Used for incremental invalidation in LSP servers and build systems.
    ///
    /// O(edges) — iterates the `file_references` forward index (file → symbol
    /// keys it references) which is always current, then resolves each symbol
    /// to its defining file via O(1) lookup.  Total cost is O(E) where E is the
    /// number of (file, symbol) reference edges, vs. the old O(F × S × R) scan.
    pub fn dependency_graph(&self) -> crate::DependencyGraph {
        let db = self.snapshot_db();

        let all_files: Vec<String> = db
            .source_file_paths()
            .iter()
            .map(|f| f.as_ref().to_string())
            .collect();

        let mut dependencies: HashMap<String, Vec<String>> = HashMap::default();
        let mut dependents: HashMap<String, Vec<String>> = HashMap::default();

        for file in &all_files {
            // O(degree(file)) — forward index lookup, no full-table scan.
            let symbol_keys = db.file_referenced_symbols(file);
            let mut file_deps: HashSet<String> = HashSet::default();
            for symbol_key in &symbol_keys {
                let lookup: &str = match symbol_key.split_once("::") {
                    Some((class, _)) => class,
                    None => symbol_key.as_ref(),
                };
                if let Some(def_file) = db.symbol_defining_file(lookup) {
                    let def = def_file.as_ref().to_string();
                    if &def != file {
                        file_deps.insert(def);
                    }
                }
            }
            for dep in &file_deps {
                dependents
                    .entry(dep.clone())
                    .or_default()
                    .push(file.clone());
                dependencies
                    .entry(file.clone())
                    .or_default()
                    .push(dep.clone());
            }
        }

        // Merge structural deps derived from definition collection. The
        // forward pass above only captures bare-FQN references recorded
        // during body analysis; `file_structural_deps` covers imports, class
        // hierarchy (extends/implements/use), and type-hint-only references
        // that never appear in file_referenced_symbols. The query is salsa-
        // memoized, so the warm rebuild costs one map lookup per file rather
        // than a definition walk — and there is no imperatively-maintained
        // reverse map to drift out of sync with the definitions.
        for file in &all_files {
            let Some(sf) = db.lookup_source_file(file) else {
                continue;
            };
            for target in crate::db::file_structural_deps(&db, sf).iter() {
                let target = target.as_ref().to_string();
                if &target != file {
                    dependents
                        .entry(target.clone())
                        .or_default()
                        .push(file.clone());
                    dependencies.entry(file.clone()).or_default().push(target);
                }
            }
        }

        for deps in dependents.values_mut() {
            deps.sort();
            deps.dedup();
        }
        for deps in dependencies.values_mut() {
            deps.sort();
            deps.dedup();
        }

        // Augment with stale dependents: files referencing symbols that were
        // deleted from their defining file. These edges disappear from the
        // symbol_defining_file lookup but the referencing file still needs
        // re-analysis to surface the now-broken reference.
        {
            let stale = self.stale_defined_symbols.read();
            if !stale.is_empty() {
                for (file, deleted_syms) in stale.iter() {
                    for sym in deleted_syms {
                        let lookup: &str = match sym.split_once("::") {
                            Some((class, _)) => class,
                            None => sym.as_ref(),
                        };
                        for referencing_file in db.symbol_referencers_of(lookup) {
                            let ref_file = referencing_file.as_ref().to_string();
                            if &ref_file != file {
                                dependents
                                    .entry(file.clone())
                                    .or_default()
                                    .push(ref_file.clone());
                                dependencies.entry(ref_file).or_default().push(file.clone());
                            }
                        }
                    }
                }
                // Re-sort and dedup since we may have added entries.
                for deps in dependents.values_mut() {
                    deps.sort();
                    deps.dedup();
                }
                for deps in dependencies.values_mut() {
                    deps.sort();
                    deps.dedup();
                }
            }
        }

        crate::DependencyGraph {
            dependencies,
            dependents,
        }
    }
}

/// Compute the full set of files `file` depends on: structural edges from
/// the memoized [`crate::db::file_structural_deps`] tracked query, plus
/// bare-FQN references recorded during body analysis (which live in the
/// reference index and are not visible to salsa). Self-edges are excluded.
/// Used to persist the disk cache's reverse-dep graph.
fn file_outgoing_dependencies(db: &dyn MirDatabase, file: &str) -> HashSet<String> {
    let mut targets: HashSet<String> = HashSet::default();

    if let Some(sf) = db.lookup_source_file(file) {
        for target in crate::db::file_structural_deps(db, sf).iter() {
            targets.insert(target.as_ref().to_string());
        }
    }

    // Bare-FQN references recorded during body analysis (new \Foo(),
    // \Foo::method(), \foo()) that do not appear in use-import statements.
    for symbol_key in db.file_referenced_symbols(file) {
        let lookup: &str = match symbol_key.split_once("::") {
            Some((class, _)) => class,
            None => &symbol_key,
        };
        if let Some(defining_file) = db.symbol_defining_file(lookup) {
            if defining_file.as_ref() != file {
                targets.insert(defining_file.as_ref().to_string());
            }
        }
    }

    targets
}

/// AST visitor that collects class FQCN references for PSR-4 preloading.
/// Captures identifiers from `new X`, static calls / property / constant
/// access, type hints, and `instanceof`. Does *not* normalize via PSR-4 /
/// imports — callers run the raw string through `resolve_name`.
fn collect_class_refs_from_ast(program: &php_ast::owned::Program) -> Vec<String> {
    use php_ast::ast::BinaryOp;
    use php_ast::owned::visitor::{
        walk_owned_catch_clause, walk_owned_expr, walk_owned_program, walk_owned_type_hint,
        OwnedVisitor,
    };
    use php_ast::owned::{ExprKind, TypeHintKind};
    use std::ops::ControlFlow;

    fn owned_name_str(name: &php_ast::owned::Name) -> String {
        let joined: String = name
            .parts
            .iter()
            .map(|p| p.as_ref())
            .collect::<Vec<&str>>()
            .join("\\");
        if name.kind == php_ast::ast::NameKind::FullyQualified {
            format!("\\{joined}")
        } else {
            joined
        }
    }

    struct V {
        names: std::collections::HashSet<String>,
    }
    impl OwnedVisitor for V {
        fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
            match &expr.kind {
                ExprKind::New(n) => {
                    if let ExprKind::Identifier(name) = &n.class.kind {
                        self.names.insert(name.as_ref().to_string());
                    }
                }
                ExprKind::StaticMethodCall(c) => {
                    if let ExprKind::Identifier(name) = &c.class.kind {
                        self.names.insert(name.as_ref().to_string());
                    }
                }
                ExprKind::StaticPropertyAccess(a) => {
                    if let ExprKind::Identifier(name) = &a.class.kind {
                        self.names.insert(name.as_ref().to_string());
                    }
                }
                ExprKind::ClassConstAccess(a) => {
                    if let ExprKind::Identifier(name) = &a.class.kind {
                        self.names.insert(name.as_ref().to_string());
                    }
                }
                ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
                    if let ExprKind::Identifier(name) = &b.right.kind {
                        self.names.insert(name.as_ref().to_string());
                    }
                }
                _ => {}
            }
            walk_owned_expr(self, expr)
        }

        fn visit_type_hint(&mut self, hint: &php_ast::owned::TypeHint) -> ControlFlow<()> {
            if let TypeHintKind::Named(name) = &hint.kind {
                let s = owned_name_str(name);
                if !s.is_empty() {
                    self.names.insert(s);
                }
            }
            walk_owned_type_hint(self, hint)
        }

        fn visit_catch_clause(&mut self, catch: &php_ast::owned::CatchClause) -> ControlFlow<()> {
            for ty in catch.types.iter() {
                self.names.insert(owned_name_str(ty));
            }
            walk_owned_catch_clause(self, catch)
        }
    }
    let mut v = V {
        names: std::collections::HashSet::default(),
    };
    let _ = walk_owned_program(&mut v, program);
    v.names.into_iter().collect()
}
