//! `pkg_doctor` — read-only diagnosis for package state (Wave 2 of local-first DX).
//!
//! The actuator counterpart is [`super::repair`] (`pkg_repair`). `pkg_doctor`
//! classifies packages into ten buckets without touching the filesystem:
//!
//! | Bucket              | Source-of-truth                                         | Condition                                          |
//! |---------------------|---------------------------------------------------------|----------------------------------------------------|
//! | `healthy`           | `installed.json` + `~/.algocline/packages/{name}`       | dest directory exists (resolved through symlinks)  |
//! | `installed_missing` | `installed.json`                                        | dest missing (non-symlink), `pkg_install` can heal |
//! | `symlink_dangling`  | `installed.json` (manifest-pass) + filesystem scan      | dest is a symlink whose target is missing          |
//! | `stale_cache`       | `~/.algocline/hub_cache/{hash}.json`                    | mtime older than 3600s (CACHE_TTL_SECS); refresh   |
//! |                     |                                                         | via alc_hub_search                                 |
//! | `path_missing`      | `alc.toml` / `alc.local.toml`                           | declared `path = ...` does not exist               |
//! | `incomplete_pkg`    | `installed.json` + `{pkg_dir}/{name}/init.lua`          | init.lua requires sibling sub (`pkg.sub`) but      |
//! |                     |                                                         | `sub.lua` / `sub/init.lua` is missing              |
//! | `missing_meta`      | `{pkg_dir}/{name}/init.lua`                             | init.lua absent or does not declare `M.meta.name`  |
//! | `missing_hub_index` | `{project_root}/hub_index.json`                         | collection root has 2+ pkg dirs but no index file  |
//! | `spec_missing`      | `{pkg_dir}/{name}/spec/`                                | spec/ exists but contains zero `*_spec.lua` files  |
//! | `unregistered_pkg`  | `~/.algocline/packages/{name}/init.lua`                 | physical dir with init.lua but not registered in   |
//! |                     |                                                         | installed.json / alc.toml / alc.local.toml         |
//!
//! Contract:
//! - **No side effects.** No `fs::write`, `fs::remove_*`, `fs::create_*`,
//!   symlink operations, or `pkg_install`. Filesystem is read-only.
//! - Reuses [`super::repair`]'s `pub(super)` helpers to keep the classification
//!   logic authoritative in one place (symlink-dangling suggestion wording and
//!   the path-missing scan in particular).
//!
//! The JSON output schema always contains ten top-level buckets:
//! `healthy`, `incomplete_pkg`, `installed_missing`, `missing_hub_index`,
//! `missing_meta`, `path_missing`, `spec_missing`, `stale_cache`,
//! `symlink_dangling`, `unregistered_pkg`. The `narrative_issues` bucket was removed in
//! #1778221491-39903 (narrative SSOT decommission).
//! Key order within the serialized string follows `serde_json`'s default
//! (alphabetical when `preserve_order` is off, as it is
//! in this workspace) — the contract is "these ten keys always present", not
//! textual ordering.
//!
//! ## `incomplete_pkg` detection
//!
//! Only **static string-literal `require`** calls of the form
//! `require("pkg_name.sub")` or `require('pkg_name.sub')` are scanned.
//! Dynamic require forms (`require(variable)`) and non-quoted forms
//! (`require "foo.bar"`) are **not** detected (MVP scope — false negatives
//! are acceptable; false positives are not). A future version may use mlua
//! to perform a real module resolution dry-run.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

use algocline_core::PkgEntity;
use tracing::warn;

use super::super::alc_toml::{load_alc_local_toml, load_alc_toml, PackageDep};
use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
use super::super::resolve::packages_dir;
use super::super::source::PackageSource;
use super::super::AppService;
use super::repair::{
    collect_path_missing, collect_unattached_dangling_symlinks, collect_unregistered_pkg_dirs,
    symlink_dangling_suggestion, AliveBucket, ProjectPathSource,
};

/// Same TTL discipline as `hub::CACHE_TTL_SECS` (hub.rs:96).
/// Both consts must be kept in sync; if you change one, change the other.
/// (hub's const is module-private, so we redefine here instead of importing.)
const DOCTOR_CACHE_TTL_SECS: u64 = 3600;

/// Classification of a single manifest-tracked package (read-only).
#[derive(Debug)]
enum DoctorOutcome {
    /// Destination exists and is reachable — no action required.
    Healthy,
    /// Destination is a symlink whose target is missing.
    SymlinkDangling { reason: String, suggestion: String },
    /// Destination is missing (non-symlink). Install can heal from `source`.
    InstalledMissing { reason: String, suggestion: String },
    /// Package directory exists but one or more submodule files required by
    /// `init.lua` are missing.
    IncompletePkg {
        missing_subs: Vec<String>,
        suggestion: String,
    },
    /// Package directory exists but `init.lua` does not declare a valid
    /// `M.meta.name` field (parse failure, missing field, or absent file).
    MissingMeta { reason: String, suggestion: String },
    /// Package directory exists and has a `spec/` directory, but contains
    /// zero `*_spec.lua` files (opt-in: pkgs without `spec/` are skipped).
    SpecMissing { reason: String, suggestion: String },
}

/// Accumulator for the JSON output buckets.
#[derive(Default)]
struct DoctorBuckets {
    healthy: Vec<serde_json::Value>,
    installed_missing: Vec<serde_json::Value>,
    symlink_dangling: Vec<serde_json::Value>,
    path_missing: Vec<serde_json::Value>,
    incomplete_pkg: Vec<serde_json::Value>,
    missing_meta: Vec<serde_json::Value>,
    missing_hub_index: Vec<serde_json::Value>,
    spec_missing: Vec<serde_json::Value>,
    stale_cache: Vec<serde_json::Value>,
    /// Physical directories under `~/.algocline/packages/` that contain
    /// `init.lua` but are not registered in any of the three authoritative
    /// sources (installed.json / alc.toml / alc.local.toml).
    ///
    /// Unlike all other buckets, entries here carry `suggestion: array<string>`
    /// (Clippy-style multi-line) instead of `suggestion: string`.
    unregistered_pkg: Vec<serde_json::Value>,
}

impl DoctorBuckets {
    fn any_matched(&self) -> bool {
        !self.healthy.is_empty()
            || !self.installed_missing.is_empty()
            || !self.symlink_dangling.is_empty()
            || !self.path_missing.is_empty()
            || !self.incomplete_pkg.is_empty()
            || !self.missing_meta.is_empty()
            || !self.missing_hub_index.is_empty()
            || !self.spec_missing.is_empty()
            || !self.stale_cache.is_empty()
            || !self.unregistered_pkg.is_empty()
    }

    fn into_json(self) -> String {
        // All buckets are always emitted (empty arrays when no entries).
        // `serde_json::json!` serializes keys alphabetically without the
        // `preserve_order` feature — consumers parse as a Map, not by order.
        serde_json::json!({
            "healthy": self.healthy,
            "incomplete_pkg": self.incomplete_pkg,
            "installed_missing": self.installed_missing,
            "missing_hub_index": self.missing_hub_index,
            "missing_meta": self.missing_meta,
            "path_missing": self.path_missing,
            "spec_missing": self.spec_missing,
            "stale_cache": self.stale_cache,
            "symlink_dangling": self.symlink_dangling,
            "unregistered_pkg": self.unregistered_pkg,
        })
        .to_string()
    }
}

/// Parse static string-literal `require` calls from Lua source and return the
/// list of submodule names that belong to `pkg_name`.
///
/// Recognized pattern (parenthesised string literal, single or double quote):
/// ```text
/// require("pkg_name.sub")
/// require('pkg_name.sub')
/// ```
///
/// **Not** recognized (MVP scope — false negatives are acceptable):
/// - `require "foo.bar"` (no parentheses)
/// - `require(variable)` (dynamic)
/// - `require([[foo.bar]])` (long-string literal)
fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
    let mut subs = Vec::new();
    let prefix = format!("{pkg_name}.");
    let mut remaining = lua_src;

    while let Some(pos) = remaining.find("require") {
        remaining = &remaining[pos + "require".len()..];

        // Skip whitespace after `require`.
        let trimmed = remaining.trim_start_matches([' ', '\t']);

        // Must be followed by `(`.
        if !trimmed.starts_with('(') {
            continue;
        }
        let after_paren = &trimmed[1..];
        let after_paren = after_paren.trim_start_matches([' ', '\t']);

        // Must be followed by a string quote.
        let quote = match after_paren.chars().next() {
            Some(q @ '"') | Some(q @ '\'') => q,
            _ => continue,
        };
        let content = &after_paren[1..];
        let end = match content.find(quote) {
            Some(i) => i,
            None => continue,
        };
        let module = &content[..end];

        if let Some(sub) = module.strip_prefix(&prefix) {
            if !sub.is_empty() && !sub.contains('.') {
                // Only direct children: `pkg.sub`, not `pkg.sub.deeper`.
                subs.push(sub.to_string());
            }
        }
    }

    subs.sort();
    subs.dedup();
    subs
}

/// Build the suggestion string for an `incomplete_pkg` entry, branched by
/// whether the package came from a symlink (link path) or an installed copy.
fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
    if is_symlink {
        format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
    } else {
        format!(
            "Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
        )
    }
}

/// Suggestion string for `installed_missing`, branched by source kind to
/// mirror `pkg_repair`'s routing:
///
/// - `Git` → `alc_pkg_install(<url>)`
/// - `Path` → `alc_pkg_install(<path>)` (local re-copy)
/// - `Bundled` → `alc_init` (bundled packages cannot be reinstalled via
///   `alc_pkg_install`; they ship inside the algocline binary)
/// - `Installed` → legacy marker with no re-fetch info (user must re-record
///   source via `alc_pkg_install`)
/// - `Unknown` → reindex + reinstall (pre-typed manifest with no source)
fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
    match entry_source {
        PackageSource::Bundled { .. } => {
            "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
        }
        PackageSource::Path { path } => {
            format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
        }
        PackageSource::Git { url, .. } => {
            format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
        }
        PackageSource::Installed => {
            format!(
                "alc_pkg_install <path-or-url> to re-record source for {name:?} \
                 (legacy 'installed' marker carries no path)"
            )
        }
        PackageSource::Unknown => {
            format!(
                "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
                 (source unknown — legacy entry)"
            )
        }
    }
}

/// Push a manifest-pass outcome into the appropriate bucket.
fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
    match outcome {
        DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
            "name": name,
        })),
        DoctorOutcome::SymlinkDangling { reason, suggestion } => {
            buckets.symlink_dangling.push(serde_json::json!({
                "name": name,
                "kind": "symlink_dangling",
                "reason": reason,
                "suggestion": suggestion,
            }))
        }
        DoctorOutcome::InstalledMissing { reason, suggestion } => {
            buckets.installed_missing.push(serde_json::json!({
                "name": name,
                "kind": "installed_missing",
                "reason": reason,
                "suggestion": suggestion,
            }))
        }
        DoctorOutcome::IncompletePkg {
            missing_subs,
            suggestion,
        } => buckets.incomplete_pkg.push(serde_json::json!({
            "name": name,
            "kind": "incomplete_pkg",
            "missing_subs": missing_subs,
            "suggestion": suggestion,
        })),
        DoctorOutcome::MissingMeta { reason, suggestion } => {
            buckets.missing_meta.push(serde_json::json!({
                "name": name,
                "kind": "missing_meta",
                "reason": reason,
                "suggestion": suggestion,
            }))
        }
        DoctorOutcome::SpecMissing { reason, suggestion } => {
            buckets.spec_missing.push(serde_json::json!({
                "name": name,
                "kind": "spec_missing",
                "reason": reason,
                "suggestion": suggestion,
            }))
        }
    }
}

/// Check whether the package directory at `dest` is incomplete: read
/// `init.lua`, extract static `require("pkg.sub")` calls, and verify that
/// each referenced sub-file exists as `{dest}/{sub}.lua` or
/// `{dest}/{sub}/init.lua`.
///
/// Returns `Some(DoctorOutcome::IncompletePkg { .. })` when one or more
/// submodule files are missing, `None` when everything is present or when
/// `init.lua` cannot be read (IO errors are logged as warnings and treated as
/// "no incomplete evidence" rather than propagated — the directory-level
/// `Healthy` classification already passed and the init.lua read is
/// best-effort).
fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
    let init_lua = dest.join("init.lua");
    let src = match std::fs::read_to_string(&init_lua) {
        Ok(s) => s,
        Err(e) => {
            warn!(
                error = %e,
                path = %init_lua.display(),
                "could not read init.lua for incomplete check; skipping"
            );
            return None;
        }
    };

    let required_subs = extract_required_subs(&src, name);
    if required_subs.is_empty() {
        return None;
    }

    let missing: Vec<String> = required_subs
        .into_iter()
        .filter(|sub| {
            let as_file = dest.join(format!("{sub}.lua"));
            let as_dir = dest.join(sub).join("init.lua");
            !as_file.exists() && !as_dir.exists()
        })
        .collect();

    if missing.is_empty() {
        return None;
    }

    Some(DoctorOutcome::IncompletePkg {
        missing_subs: missing,
        suggestion: incomplete_pkg_suggestion(name, is_symlink),
    })
}

/// Check whether the package directory at `dest` has a `spec/` directory
/// that exists but contains zero `*_spec.lua` files. Narrow scope (opt-in):
/// packages without a `spec/` directory return `Ok(None)` silently.
///
/// Aligns with `alc_pkg_test`'s spec discovery convention
/// (`<pkg_root>/spec/*_spec.lua`).
///
/// Returns `Err(String)` when `fs::read_dir` / `DirEntry::file_type` fails
/// (judgment-critical fs ops; silent drop would produce false negatives).
fn check_spec_missing(name: &str, dest: &Path) -> Result<Option<DoctorOutcome>, String> {
    let spec_dir = dest.join("spec");
    if !spec_dir.is_dir() {
        return Ok(None);
    }
    let entries = std::fs::read_dir(&spec_dir).map_err(|e| {
        format!(
            "spec_missing: failed to read_dir {}: {e}",
            spec_dir.display()
        )
    })?;
    let mut found_spec = false;
    for entry in entries {
        let entry = entry.map_err(|e| format!("spec_missing: failed to read dir entry: {e}"))?;
        let ft = entry.file_type().map_err(|e| {
            format!(
                "spec_missing: failed to read file_type for {}: {e}",
                entry.path().display()
            )
        })?;
        if !ft.is_file() {
            continue;
        }
        let fname = entry.file_name();
        if fname.to_string_lossy().ends_with("_spec.lua") {
            found_spec = true;
            break;
        }
    }
    if found_spec {
        return Ok(None);
    }
    Ok(Some(DoctorOutcome::SpecMissing {
        reason: format!(
            "spec directory at {} exists but contains zero *_spec.lua files",
            spec_dir.display()
        ),
        suggestion: format!(
            "Package {name:?} declared test intent by creating spec/ at {} — \
             add at least one <name>_spec.lua file (mlua-lspec convention) or remove \
             the spec/ directory to opt out of spec discipline",
            spec_dir.display()
        ),
    }))
}

/// Classify a manifest entry by inspecting only the destination directory.
/// Mirrors the pre-install branch of [`super::repair::repair_installed`] but
/// never attempts an install.
///
/// After confirming the package directory is reachable, performs three
/// sequential best-effort checks: missing submodule files, missing meta,
/// and missing spec.
///
/// `parse_from_init_lua` is called once and reused across all meta-dependent
/// checks to avoid redundant I/O.
fn classify_installed(
    name: &str,
    entry: &ManifestEntry,
    pkg_dir: &Path,
) -> Result<DoctorOutcome, String> {
    let dest = pkg_dir.join(name);

    let is_symlink = dest
        .symlink_metadata()
        .map(|m| m.file_type().is_symlink())
        .unwrap_or(false);
    if is_symlink {
        // `try_exists` follows the symlink — true iff target is alive.
        let target_alive = match dest.try_exists() {
            Ok(v) => v,
            Err(e) => {
                warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
                false
            }
        };
        if !target_alive {
            let link_target = match dest.read_link() {
                Ok(t) => t.display().to_string(),
                Err(e) => {
                    warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
                    "<unknown>".to_string()
                }
            };
            return Ok(DoctorOutcome::SymlinkDangling {
                reason: format!("symlink target missing: {link_target}"),
                suggestion: symlink_dangling_suggestion(name),
            });
        }
        // Symlink alive — parse init.lua once, then run all sequential checks.
        let init_lua = dest.join("init.lua");
        let entity = PkgEntity::parse_from_init_lua(&init_lua);
        if let Some(incomplete) = check_incomplete(name, &dest, true) {
            return Ok(incomplete);
        }
        if entity.is_none() {
            return Ok(DoctorOutcome::MissingMeta {
                reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
                suggestion: format!(
                    "Package directory at {} lacks M.meta.name in init.lua — \
                     run alc_pkg_install --force {name:?} or fix init.lua to declare \
                     M.meta = {{ name = ..., version = ... }}",
                    dest.display()
                ),
            });
        }
        // entity is Some here (is_none() returned early above).
        if entity.is_some() {
            if let Some(sm) = check_spec_missing(name, &dest)? {
                return Ok(sm);
            }
        }
        return Ok(DoctorOutcome::Healthy);
    }

    if dest.exists() {
        // Directory exists — parse init.lua once, then run all sequential checks.
        let init_lua = dest.join("init.lua");
        let entity = PkgEntity::parse_from_init_lua(&init_lua);
        if let Some(incomplete) = check_incomplete(name, &dest, false) {
            return Ok(incomplete);
        }
        if entity.is_none() {
            return Ok(DoctorOutcome::MissingMeta {
                reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
                suggestion: format!(
                    "Package directory at {} lacks M.meta.name in init.lua — \
                     run alc_pkg_install --force {name:?} or fix init.lua to declare \
                     M.meta = {{ name = ..., version = ... }}",
                    dest.display()
                ),
            });
        }
        // entity is Some here (is_none() returned early above).
        if entity.is_some() {
            if let Some(sm) = check_spec_missing(name, &dest)? {
                return Ok(sm);
            }
        }
        return Ok(DoctorOutcome::Healthy);
    }

    Ok(DoctorOutcome::InstalledMissing {
        reason: format!("installed directory missing: {}", dest.display()),
        suggestion: installed_missing_suggestion(name, &entry.source),
    })
}

/// Classify every manifest entry into the four buckets. When `target_filter`
/// is `Some(name)`, look the entry up directly (O(log N) on BTreeMap) instead
/// of scanning the full map.
fn run_manifest_pass(
    manifest: &Manifest,
    target_filter: Option<&str>,
    pkg_dir: &Path,
    buckets: &mut DoctorBuckets,
) -> Result<(), String> {
    if let Some(target) = target_filter {
        if let Some(entry) = manifest.packages.get(target) {
            let outcome = classify_installed(target, entry, pkg_dir)?;
            push_doctor_outcome(target, outcome, buckets);
        }
        return Ok(());
    }
    for (pkg_name, entry) in &manifest.packages {
        let outcome = classify_installed(pkg_name, entry, pkg_dir)?;
        push_doctor_outcome(pkg_name, outcome, buckets);
    }
    Ok(())
}

/// Drain the unattached-symlink scan results into the `symlink_dangling`
/// bucket. The shared helper writes tagged entries into a scratch vec so
/// its signature can stay aligned with `pkg_repair`'s unrepairable bucket.
fn run_unattached_symlink_pass(
    pkg_dir: &Path,
    target_filter: Option<&str>,
    manifest: &Manifest,
    buckets: &mut DoctorBuckets,
) {
    let mut scratch: Vec<serde_json::Value> = Vec::new();
    collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
    buckets.symlink_dangling.extend(scratch);
}

/// Scan `alc.toml` + `alc.local.toml` for declared paths that no longer
/// resolve. `resolved_root = None` means no project context was located,
/// which mirrors `pkg_repair`'s skip-on-missing behavior.
fn run_path_missing_pass(
    resolved_root: Option<&Path>,
    target_filter: Option<&str>,
    buckets: &mut DoctorBuckets,
) {
    let Some(root) = resolved_root else {
        return;
    };
    let mut scratch: Vec<serde_json::Value> = Vec::new();
    collect_path_missing(
        root,
        target_filter,
        "project",
        &mut scratch,
        ProjectPathSource::Toml,
    );
    collect_path_missing(
        root,
        target_filter,
        "variant",
        &mut scratch,
        ProjectPathSource::Local,
    );
    buckets.path_missing.extend(scratch);
}

/// Scan the collection project root for a missing `hub_index.json`.
///
/// Fires when **all three** conditions hold:
/// 1. Called only when `target_filter` is `None` and `resolved_root` is `Some`
///    (enforced by the caller in [`AppService::pkg_doctor`]).
/// 2. Two or more direct subdirectories of `root` each contain an `init.lua`
///    file (collection-repo heuristic — single-pkg repos have fewer than 2).
/// 3. `{root}/hub_index.json` does not exist.
///
/// All `fs` errors are propagated through `?` to the MCP wire layer; none are
/// silently swallowed.
///
/// # Errors
///
/// Returns `Err(String)` when `fs::read_dir`, `DirEntry::file_type`, or
/// `Path::try_exists` fails. The error message carries enough context to
/// identify which path triggered the failure.
fn run_hub_index_pass(root: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
    let mut pkg_count = 0usize;
    let entries = std::fs::read_dir(root).map_err(|e| {
        format!(
            "hub_index_pass: failed to read project_root {}: {e}",
            root.display()
        )
    })?;
    for entry in entries {
        let entry = entry.map_err(|e| format!("hub_index_pass: failed to read dir entry: {e}"))?;
        let ft = entry
            .file_type()
            .map_err(|e| format!("hub_index_pass: failed to read file_type: {e}"))?;
        if !ft.is_dir() {
            continue;
        }
        let init_lua = entry.path().join("init.lua");
        let exists = init_lua.try_exists().map_err(|e| {
            format!(
                "hub_index_pass: try_exists failed for {}: {e}",
                init_lua.display()
            )
        })?;
        if exists {
            pkg_count += 1;
        }
    }
    if pkg_count < 2 {
        return Ok(());
    }
    let hub_index = root.join("hub_index.json");
    let has_index = hub_index.try_exists().map_err(|e| {
        format!(
            "hub_index_pass: try_exists failed for {}: {e}",
            hub_index.display()
        )
    })?;
    if has_index {
        return Ok(());
    }
    buckets.missing_hub_index.push(serde_json::json!({
        "kind": "missing_hub_index",
        "project_root": root.display().to_string(),
        "pkg_count": pkg_count,
        "suggestion": format!(
            "Collection project root contains {pkg_count} package dirs but \
             {}/hub_index.json is missing — run alc_hub_reindex --source_dir {} \
             to generate it",
            root.display(),
            root.display()
        ),
    }));
    Ok(())
}

/// Scan `cache_dir` (= `~/.algocline/hub_cache/`) for `*.json` files whose
/// mtime is older than `DOCTOR_CACHE_TTL_SECS` (3600s) and emit one
/// `stale_cache` entry per stale file. Skips when `cache_dir` does not
/// exist (cache miss = first run = normal, not an error).
///
/// `mtime` retrieval failures (`metadata.modified()` / `.elapsed()`) are
/// treated as "judgment impossible → safe-side fresh" and the file is
/// skipped, *not* silently dropped. This is an explicit cross-platform
/// fallback (older filesystems may not support `modified()`, and a system
/// clock that drifted earlier than the file's mtime produces
/// `SystemTimeError` from `.elapsed()`).
///
/// Returns `Err(String)` when `try_exists` / `read_dir` / `file_type` /
/// `metadata` fail (these are judgment-critical fs ops; silent drop would
/// produce false negatives).
fn run_stale_cache_pass(cache_dir: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
    let exists = cache_dir.try_exists().map_err(|e| {
        format!(
            "stale_cache_pass: try_exists failed for {}: {e}",
            cache_dir.display()
        )
    })?;
    if !exists {
        return Ok(());
    }
    let entries = std::fs::read_dir(cache_dir).map_err(|e| {
        format!(
            "stale_cache_pass: failed to read_dir {}: {e}",
            cache_dir.display()
        )
    })?;
    for entry in entries {
        let entry =
            entry.map_err(|e| format!("stale_cache_pass: failed to read dir entry: {e}"))?;
        let ft = entry.file_type().map_err(|e| {
            format!(
                "stale_cache_pass: failed to read file_type for {}: {e}",
                entry.path().display()
            )
        })?;
        if !ft.is_file() {
            continue;
        }
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }
        let metadata = entry.metadata().map_err(|e| {
            format!(
                "stale_cache_pass: failed to read metadata for {}: {e}",
                path.display()
            )
        })?;
        // mtime retrieval — `.ok()` here is intentional cross-platform
        // fallback (see fn doc), not a silent error drop.
        let Some(modified) = metadata.modified().ok() else {
            continue;
        };
        let Some(age) = modified.elapsed().ok() else {
            continue;
        };
        if age.as_secs() <= DOCTOR_CACHE_TTL_SECS {
            continue;
        }
        buckets.stale_cache.push(serde_json::json!({
            "kind": "stale_cache",
            "path": path.display().to_string(),
            "age_secs": age.as_secs(),
            "suggestion": format!(
                "Run alc_hub_search to refresh stale cache (>{DOCTOR_CACHE_TTL_SECS}s old)"
            ),
        }));
    }
    Ok(())
}

/// Walk `pkg_dir` for physical directories with `init.lua` that are not
/// registered in any manifest source, and push them into
/// `buckets.unregistered_pkg`.
///
/// # Arguments
///
/// * `pkg_dir` — `~/.algocline/packages/`
/// * `registered` — set of package names from all three registration sources
/// * `registered_paths` — canonicalized `path` entries from alc.toml /
///   alc.local.toml; dirs whose canonical path matches are skipped to avoid
///   false positives (crux constraint: canonical path comparison)
/// * `target_filter` — when `Some(name)`, restrict to that single package
/// * `buckets` — accumulator; entries are pushed into `buckets.unregistered_pkg`
///
/// # Errors
///
/// Propagates `Err(String)` from `collect_unregistered_pkg_dirs` when
/// `pkg_dir` exists but cannot be read.
fn run_unregistered_pkg_pass(
    pkg_dir: &Path,
    registered: &HashSet<String>,
    registered_paths: &[PathBuf],
    target_filter: Option<&str>,
    buckets: &mut DoctorBuckets,
) -> Result<(), String> {
    let found =
        collect_unregistered_pkg_dirs(pkg_dir, registered, registered_paths, target_filter)?;
    buckets.unregistered_pkg.extend(found);
    Ok(())
}

impl AppService {
    /// Walk `pkg_dir` for **alive symlinks** (target exists, `init.lua` present) that
    /// are not registered in any authoritative source, then push entries into
    /// `buckets.unregistered_pkg`.
    ///
    /// # Arguments
    ///
    /// * `pkg_dir` — `~/.algocline/packages/` root
    /// * `registered` — set of package names from installed.json + alc.toml + alc.local.toml
    /// * `registered_paths` — canonicalized paths from path-dep entries to suppress
    ///   false positives (crux constraint: canonical path comparison)
    /// * `target_filter` — when `Some(name)`, restrict to that single package
    /// * `buckets` — accumulator; entries are pushed into `buckets.unregistered_pkg`
    ///
    /// # Returns
    ///
    /// `Ok(())` after all alive symlinks have been classified and appended to
    /// the appropriate bucket.
    ///
    /// # Errors
    ///
    /// Propagates `Err(String)` from `collect_alive_unregistered_symlinks` when
    /// `pkg_dir` exists but cannot be read. Per-package eval failures are logged
    /// via `tracing::warn!` and fall through to `Unregistered` (no propagation).
    async fn run_alive_unregistered_symlink_pass(
        &self,
        pkg_dir: &Path,
        registered: &HashSet<String>,
        registered_paths: &[PathBuf],
        target_filter: Option<&str>,
        buckets: &mut DoctorBuckets,
    ) -> Result<(), String> {
        let found = self
            .collect_alive_unregistered_symlinks(
                pkg_dir,
                registered,
                registered_paths,
                target_filter,
            )
            .await?;
        for (name, bucket) in found {
            match bucket {
                AliveBucket::Unregistered => {
                    let abs_path = pkg_dir.join(&name).display().to_string();
                    let suggestion = serde_json::json!([
                        format!(
                            "If this pkg was scaffolded outside `alc_pkg_scaffold` and you want it installed: \
                            `alc_pkg_install --force {abs_path}` (re-copy + register in installed.json)"
                        ),
                        format!(
                            "If you are actively iterating on this pkg in-tree: \
                            `alc_pkg_link {abs_path}` (symlink-based, no copy)"
                        ),
                        format!("If this dir is stale/abandoned: `rm -rf {abs_path}` to clean it up"),
                        "Note: source is unknown — git URL cannot be inferred from the bare directory. \
                        Re-record via one of the above."
                            .to_string(),
                    ]);
                    buckets.unregistered_pkg.push(serde_json::json!({
                        "name": name,
                        "kind": "unregistered_pkg",
                        "source": "unknown",
                        "reason": format!(
                            "alive symlink with init.lua exists but is not registered in \
                            installed.json, alc.toml, or alc.local.toml: <symlink path: '{abs_path}'>"
                        ),
                        "suggestion": suggestion,
                    }));
                }
            }
        }
        Ok(())
    }
}

impl AppService {
    /// Diagnose package state without any side effects. Returns a JSON string
    /// with ten arrays (`healthy`, `incomplete_pkg`, `installed_missing`,
    /// `missing_hub_index`, `missing_meta`, `path_missing`, `spec_missing`,
    /// `stale_cache`, `symlink_dangling`, `unregistered_pkg`).
    ///
    /// The `unmarked_library` bucket was removed in v0.41.0 — packages are
    /// no longer expected to declare an explicit `M.meta.type`; type detection
    /// is solely handled via VM eval at run/eval time.
    ///
    /// `name` restricts the report to a single package; `None` inspects every
    /// known package. `project_root` is only consulted for the
    /// `alc.toml` / `alc.local.toml` pass and the `missing_hub_index` scan.
    /// Falls back to ancestor walk from cwd when `None`.
    ///
    /// Error surface matches `pkg_repair`:
    /// - `load_manifest()` / `packages_dir()` failures propagate via `?`.
    /// - Per-entry `fs::read_dir` errors inside the unattached-symlink scan
    ///   are logged via `tracing::warn!` and skipped (helper's behavior).
    /// - `init.lua` read errors during the incomplete check are logged via
    ///   `tracing::warn!` and skipped (best-effort, no propagation).
    /// - `fs::read_dir` / `try_exists` errors inside `run_hub_index_pass`
    ///   propagate via `?` (collection-root scan requires reliable fs access).
    /// - `try_exists` / `read_dir` / `file_type` / `metadata` errors inside
    ///   `run_stale_cache_pass` propagate via `?`. `metadata.modified()` and
    ///   `.elapsed()` failures (cross-platform fallback) skip the file.
    /// - When `name = Some(target)` and every bucket ends empty, returns
    ///   `Err` with the same wording used by `pkg_repair`.
    pub async fn pkg_doctor(
        &self,
        name: Option<String>,
        project_root: Option<String>,
    ) -> Result<String, String> {
        let app_dir = self.log_config.app_dir();
        let manifest = load_manifest(&app_dir)?;
        let pkg_dir = packages_dir(&app_dir);
        let resolved_root = self.resolve_root(project_root.as_deref());
        let target_filter = name.as_deref();

        // Build the set of registered package names from all three sources:
        // installed.json (manifest), alc.toml [packages], alc.local.toml [packages].
        // Also collect canonicalized paths from path-dep entries to avoid false
        // positives in unregistered_pkg detection (crux constraint).
        let mut registered: HashSet<String> = manifest.packages.keys().cloned().collect();
        let mut registered_paths: Vec<PathBuf> = Vec::new();

        if let Some(ref root) = resolved_root {
            // alc.toml
            if let Some(toml_data) = load_alc_toml(root)? {
                for (name, dep) in &toml_data.packages {
                    registered.insert(name.clone());
                    if let PackageDep::Path { path, .. } = dep {
                        let raw = std::path::Path::new(path);
                        let abs = if raw.is_absolute() {
                            raw.to_path_buf()
                        } else {
                            root.join(raw)
                        };
                        match abs.canonicalize() {
                            Ok(c) => registered_paths.push(c),
                            Err(e) => {
                                // Path entry in alc.toml may point to a
                                // non-existent dir (caught by path_missing pass).
                                // Canonicalize failure here is expected; skip
                                // with a warn to avoid false positives being
                                // silently missed.
                                tracing::warn!(
                                    "pkg: cannot canonicalize alc.toml path entry \
                                    for '{}' ({}): {e}",
                                    name,
                                    abs.display()
                                );
                            }
                        }
                    }
                }
            }
            // alc.local.toml
            if let Some(local_data) = load_alc_local_toml(root)? {
                for (name, dep) in &local_data.packages {
                    registered.insert(name.clone());
                    if let PackageDep::Path { path, .. } = dep {
                        let raw = std::path::Path::new(path);
                        let abs = if raw.is_absolute() {
                            raw.to_path_buf()
                        } else {
                            root.join(raw)
                        };
                        match abs.canonicalize() {
                            Ok(c) => registered_paths.push(c),
                            Err(e) => {
                                tracing::warn!(
                                    "pkg: cannot canonicalize alc.local.toml path entry \
                                    for '{}' ({}): {e}",
                                    name,
                                    abs.display()
                                );
                            }
                        }
                    }
                }
            }
        }

        let mut buckets = DoctorBuckets::default();
        run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets)?;
        run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
        run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
        run_unregistered_pkg_pass(
            &pkg_dir,
            &registered,
            &registered_paths,
            target_filter,
            &mut buckets,
        )?;
        self.run_alive_unregistered_symlink_pass(
            &pkg_dir,
            &registered,
            &registered_paths,
            target_filter,
            &mut buckets,
        )
        .await?;
        if target_filter.is_none() {
            run_stale_cache_pass(&app_dir.hub_cache_dir(), &mut buckets)?;
            if let Some(ref root) = resolved_root {
                run_hub_index_pass(root, &mut buckets)?;
            }
        }

        if let Some(target) = target_filter {
            if !buckets.any_matched() {
                return Err(format!(
                    "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
                ));
            }
        }

        Ok(buckets.into_json())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    use crate::service::test_support::make_app_service_at;

    /// Build a minimal `ManifestEntry` with a `PackageSource::Path`.
    /// Takes a legacy path string so the existing tests keep reading
    /// naturally; the arg is wrapped into the typed `Path` variant.
    fn mk_entry(source: &str) -> ManifestEntry {
        ManifestEntry {
            version: None,
            source: PackageSource::Path {
                path: source.to_string(),
            },
            installed_at: "2026-01-01T00:00:00Z".to_string(),
            updated_at: "2026-01-01T00:00:00Z".to_string(),
            pkg_type: None,
        }
    }

    #[test]
    fn classify_installed_healthy_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let pkg_dir = tmp.path();
        let dest = pkg_dir.join("p");
        std::fs::create_dir(&dest).unwrap();
        // init.lua with valid M.meta — no explicit type needed; UnmarkedLibrary is gone.
        std::fs::write(
            dest.join("init.lua"),
            "local M = {} M.meta = { name = \"p\", version = \"0.1.0\" } return M",
        )
        .unwrap();

        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
        assert!(matches!(outcome, DoctorOutcome::Healthy));
    }

    #[test]
    fn classify_installed_missing_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let pkg_dir = tmp.path();

        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
        match outcome {
            DoctorOutcome::InstalledMissing { reason, suggestion } => {
                assert!(
                    reason.contains("installed directory missing"),
                    "reason = {reason}"
                );
                assert!(
                    suggestion.contains("alc_pkg_install"),
                    "suggestion = {suggestion}"
                );
                assert!(
                    suggestion.contains("/src/p"),
                    "suggestion carries source: {suggestion}"
                );
            }
            _ => panic!("expected InstalledMissing"),
        }
    }

    #[test]
    #[cfg(unix)]
    fn classify_installed_symlink_dangling() {
        use std::os::unix::fs::symlink;

        let tmp = tempfile::tempdir().unwrap();
        let pkg_dir = tmp.path();
        let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
        symlink(&dangling_target, pkg_dir.join("p")).unwrap();

        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
        match outcome {
            DoctorOutcome::SymlinkDangling { reason, suggestion } => {
                assert!(reason.contains("symlink target missing"), "{reason}");
                assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
            }
            _ => panic!("expected SymlinkDangling"),
        }
    }

    #[test]
    #[cfg(unix)]
    fn classify_installed_symlink_alive() {
        use std::os::unix::fs::symlink;

        let tmp = tempfile::tempdir().unwrap();
        let real_target = tmp.path().join("real_target_dir");
        std::fs::create_dir(&real_target).unwrap();
        // init.lua with valid M.meta — no explicit type needed; UnmarkedLibrary is gone.
        std::fs::write(
            real_target.join("init.lua"),
            "local M = {} M.meta = { name = \"q\", version = \"0.1.0\" } return M",
        )
        .unwrap();

        let pkg_dir = tmp.path().join("pkgs");
        std::fs::create_dir(&pkg_dir).unwrap();
        symlink(&real_target, pkg_dir.join("q")).unwrap();

        let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir).expect("classify ok");
        assert!(matches!(outcome, DoctorOutcome::Healthy));
    }

    #[test]
    fn buckets_into_json_emits_all_ten_keys() {
        // NOTE: `serde_json` without the `preserve_order` feature emits JSON
        // object keys in alphabetical order, matching `pkg_repair`'s actual
        // behavior. The spec's "fixed order" requirement is satisfied by
        // always emitting these ten top-level keys; consumers parse as a
        // Map rather than relying on textual key order.
        //
        // Note: `narrative_issues` bucket removed in #1778221491-39903.
        // Note: `unregistered_pkg` bucket added (physical dir without manifest entry).
        // Note: `unmarked_library` bucket removed — type detection is VM eval only.
        let mut b = DoctorBuckets::default();
        b.healthy.push(serde_json::json!({"name": "h"}));
        b.installed_missing
            .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
        b.symlink_dangling
            .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
        b.path_missing
            .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
        b.incomplete_pkg
            .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
        b.missing_meta
            .push(serde_json::json!({"name": "m", "kind": "missing_meta"}));
        b.missing_hub_index
            .push(serde_json::json!({"kind": "missing_hub_index", "project_root": "/r"}));
        b.spec_missing
            .push(serde_json::json!({"name": "sm", "kind": "spec_missing"}));
        b.stale_cache
            .push(serde_json::json!({"kind": "stale_cache", "path": "/p", "age_secs": 7200}));
        b.unregistered_pkg.push(serde_json::json!({
            "name": "u",
            "kind": "unregistered_pkg",
            "source": "unknown",
            "reason": "physical dir with init.lua exists but is not registered",
            "suggestion": ["install", "link", "rm -rf", "note: unknown source"],
        }));

        let out = b.into_json();
        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
        let obj = parsed.as_object().expect("JSON object");
        assert!(obj.contains_key("healthy"));
        assert!(obj.contains_key("installed_missing"));
        assert!(obj.contains_key("symlink_dangling"));
        assert!(obj.contains_key("path_missing"));
        assert!(obj.contains_key("incomplete_pkg"));
        assert!(obj.contains_key("missing_meta"));
        assert!(obj.contains_key("missing_hub_index"));
        assert!(obj.contains_key("spec_missing"));
        assert!(obj.contains_key("stale_cache"));
        assert!(obj.contains_key("unregistered_pkg"));
        assert!(
            !obj.contains_key("unmarked_library"),
            "unmarked_library must not be emitted"
        );
        assert_eq!(obj.len(), 10, "exactly ten top-level buckets: {out}");

        assert_eq!(obj["healthy"][0]["name"], "h");
        assert_eq!(obj["installed_missing"][0]["name"], "i");
        assert_eq!(obj["symlink_dangling"][0]["name"], "s");
        assert_eq!(obj["path_missing"][0]["name"], "p");
        assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
        assert_eq!(obj["missing_meta"][0]["name"], "m");
        assert_eq!(obj["missing_hub_index"][0]["project_root"], "/r");
        assert_eq!(obj["spec_missing"][0]["name"], "sm");
        assert_eq!(obj["stale_cache"][0]["path"], "/p");
        assert_eq!(obj["unregistered_pkg"][0]["name"], "u");
        assert_eq!(obj["unregistered_pkg"][0]["kind"], "unregistered_pkg");
        // suggestion must be an array (crux constraint: array<string> for unregistered_pkg).
        assert!(
            obj["unregistered_pkg"][0]["suggestion"].is_array(),
            "unregistered_pkg suggestion must be an array"
        );
    }

    #[test]
    fn any_matched_tracks_all_buckets() {
        let mut b = DoctorBuckets::default();
        assert!(!b.any_matched());
        b.healthy.push(serde_json::json!({"name": "h"}));
        assert!(b.any_matched());

        let mut b = DoctorBuckets::default();
        b.installed_missing.push(serde_json::json!({}));
        assert!(b.any_matched());

        let mut b = DoctorBuckets::default();
        b.symlink_dangling.push(serde_json::json!({}));
        assert!(b.any_matched());

        let mut b = DoctorBuckets::default();
        b.path_missing.push(serde_json::json!({}));
        assert!(b.any_matched());

        let mut b = DoctorBuckets::default();
        b.incomplete_pkg.push(serde_json::json!({}));
        assert!(b.any_matched());

        let mut b = DoctorBuckets::default();
        b.missing_meta.push(serde_json::json!({}));
        assert!(b.any_matched());

        let mut b = DoctorBuckets::default();
        b.missing_hub_index.push(serde_json::json!({}));
        assert!(b.any_matched());

        let mut b = DoctorBuckets::default();
        b.spec_missing.push(serde_json::json!({}));
        assert!(b.any_matched());

        let mut b = DoctorBuckets::default();
        b.stale_cache.push(serde_json::json!({}));
        assert!(b.any_matched());
    }

    // ── spec_missing ─────────────────────────────────────────────────────

    /// T1: pkg has spec/foo_spec.lua → check_spec_missing returns Ok(None).
    #[test]
    fn check_spec_missing_returns_none_when_spec_file_present() {
        let tmp = tempfile::tempdir().unwrap();
        let dest = tmp.path().join("mypkg");
        std::fs::create_dir_all(dest.join("spec")).unwrap();
        std::fs::write(dest.join("spec/foo_spec.lua"), "return {}").unwrap();
        let out = check_spec_missing("mypkg", &dest).expect("must not error");
        assert!(out.is_none(), "expected None, got: {out:?}");
    }

    /// T2: pkg has empty spec/ → SpecMissing emitted with reason+suggestion.
    #[test]
    fn check_spec_missing_detects_empty_spec_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let dest = tmp.path().join("mypkg");
        std::fs::create_dir_all(dest.join("spec")).unwrap();
        let out = check_spec_missing("mypkg", &dest)
            .expect("must not error")
            .expect("expected SpecMissing");
        match out {
            DoctorOutcome::SpecMissing { reason, suggestion } => {
                assert!(reason.contains("spec"), "reason: {reason}");
                assert!(suggestion.contains("_spec.lua"), "suggestion: {suggestion}");
            }
            _ => panic!("expected SpecMissing, got {out:?}"),
        }
    }

    /// T3: pkg has spec/ with only non-spec files → SpecMissing emitted.
    #[test]
    fn check_spec_missing_detects_spec_dir_with_only_non_spec_files() {
        let tmp = tempfile::tempdir().unwrap();
        let dest = tmp.path().join("mypkg");
        std::fs::create_dir_all(dest.join("spec")).unwrap();
        std::fs::write(dest.join("spec/helper.lua"), "return {}").unwrap();
        std::fs::write(dest.join("spec/README.md"), "docs").unwrap();
        let out = check_spec_missing("mypkg", &dest)
            .expect("must not error")
            .expect("expected SpecMissing");
        assert!(matches!(out, DoctorOutcome::SpecMissing { .. }));
    }

    /// T4: pkg has no spec/ → Ok(None) (silent skip, opt-in scope).
    #[test]
    fn check_spec_missing_silently_skips_when_spec_dir_absent() {
        let tmp = tempfile::tempdir().unwrap();
        let dest = tmp.path().join("mypkg");
        std::fs::create_dir_all(&dest).unwrap();
        let out = check_spec_missing("mypkg", &dest).expect("must not error");
        assert!(
            out.is_none(),
            "expected None for absent spec/, got: {out:?}"
        );
    }

    // ── stale_cache ──────────────────────────────────────────────────────

    #[test]
    fn run_stale_cache_pass_emits_when_file_older_than_ttl() {
        let tmp = tempfile::tempdir().unwrap();
        let cache_dir = tmp.path();
        let stale_file = cache_dir.join("abc123.json");
        std::fs::write(&stale_file, "{}").unwrap();
        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
        let times = std::fs::FileTimes::new().set_modified(past);
        let f = std::fs::OpenOptions::new()
            .write(true)
            .open(&stale_file)
            .unwrap();
        f.set_times(times).unwrap();

        let mut buckets = DoctorBuckets::default();
        run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
        assert_eq!(
            buckets.stale_cache.len(),
            1,
            "expected 1 stale entry: {:?}",
            buckets.stale_cache
        );
        let entry = &buckets.stale_cache[0];
        assert_eq!(entry["kind"], "stale_cache");
        assert!(entry["path"]
            .as_str()
            .unwrap_or("")
            .ends_with("abc123.json"));
        assert!(entry["age_secs"].as_u64().unwrap_or(0) >= 7200);
    }

    #[test]
    fn run_stale_cache_pass_no_emit_for_fresh_file() {
        let tmp = tempfile::tempdir().unwrap();
        let cache_dir = tmp.path();
        let fresh_file = cache_dir.join("xyz789.json");
        std::fs::write(&fresh_file, "{}").unwrap();
        let mut buckets = DoctorBuckets::default();
        run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
        assert!(
            buckets.stale_cache.is_empty(),
            "expected no stale entries for fresh file"
        );
    }

    #[test]
    fn run_stale_cache_pass_skips_when_cache_dir_absent() {
        let tmp = tempfile::tempdir().unwrap();
        let missing_dir = tmp.path().join("nonexistent_cache");
        let mut buckets = DoctorBuckets::default();
        run_stale_cache_pass(&missing_dir, &mut buckets).expect("absent dir must skip with Ok");
        assert!(buckets.stale_cache.is_empty());
    }

    #[test]
    fn run_stale_cache_pass_ignores_non_json_files() {
        let tmp = tempfile::tempdir().unwrap();
        let cache_dir = tmp.path();
        let garbage = cache_dir.join(".DS_Store");
        std::fs::write(&garbage, "garbage").unwrap();
        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
        let times = std::fs::FileTimes::new().set_modified(past);
        let f = std::fs::OpenOptions::new()
            .write(true)
            .open(&garbage)
            .unwrap();
        f.set_times(times).unwrap();

        let mut buckets = DoctorBuckets::default();
        run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
        assert!(
            buckets.stale_cache.is_empty(),
            "non-json files must be ignored"
        );
    }

    // ── missing_meta ─────────────────────────────────────────────────────

    /// T1 (happy path): a package directory whose init.lua omits `M.meta`
    /// entirely is classified as `MissingMeta`.
    #[test]
    fn classify_installed_missing_meta_when_init_lua_lacks_meta() {
        let tmp = tempfile::tempdir().unwrap();
        let pkg_dir = tmp.path();
        let dest = pkg_dir.join("mypkg");
        std::fs::create_dir(&dest).unwrap();
        // init.lua present but declares no M.meta block.
        std::fs::write(dest.join("init.lua"), "local M = {} return M").unwrap();

        let outcome =
            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
        match outcome {
            DoctorOutcome::MissingMeta { reason, suggestion } => {
                assert!(reason.contains("lacks M.meta.name"), "reason: {reason}");
                assert!(
                    suggestion.contains("alc_pkg_install"),
                    "suggestion: {suggestion}"
                );
                assert!(
                    suggestion.contains("mypkg"),
                    "suggestion carries name: {suggestion}"
                );
            }
            _ => panic!("expected MissingMeta, got {outcome:?}"),
        }
    }

    /// T2 (edge case): `M.meta = {{ name = "" }}` — empty name string —
    /// is treated as missing meta (parse_from_init_lua returns None for empty
    /// name via option_from_str).
    #[test]
    fn classify_installed_missing_meta_when_init_lua_has_empty_meta_name() {
        let tmp = tempfile::tempdir().unwrap();
        let pkg_dir = tmp.path();
        let dest = pkg_dir.join("mypkg");
        std::fs::create_dir(&dest).unwrap();
        // M.meta with an empty name string.
        std::fs::write(
            dest.join("init.lua"),
            "local M = {} M.meta = { name = \"\", version = \"0.1.0\" } return M",
        )
        .unwrap();

        let outcome =
            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
        assert!(
            matches!(outcome, DoctorOutcome::MissingMeta { .. }),
            "expected MissingMeta for empty name, got {outcome:?}"
        );
    }

    /// T3 (no false positive): a complete init.lua with a valid `M.meta.name`
    /// must not trigger `MissingMeta`. No explicit `M.meta.type` needed.
    #[test]
    fn classify_installed_no_missing_meta_when_init_lua_complete() {
        let tmp = tempfile::tempdir().unwrap();
        let pkg_dir = tmp.path();
        let dest = pkg_dir.join("mypkg");
        std::fs::create_dir(&dest).unwrap();
        std::fs::write(
            dest.join("init.lua"),
            "local M = {} M.meta = { name = \"mypkg\", version = \"0.1.0\" } return M",
        )
        .unwrap();

        let outcome =
            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
        assert!(
            matches!(outcome, DoctorOutcome::Healthy),
            "expected Healthy for complete init.lua with explicit type, got {outcome:?}"
        );
    }

    // ── run_hub_index_pass ────────────────────────────────────────────────

    /// T1 (happy path): two subdirectories each containing init.lua, with no
    /// hub_index.json in the root — emits one `missing_hub_index` entry.
    #[test]
    fn run_hub_index_pass_emits_when_2_plus_pkgs_and_index_absent() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        // Two package dirs each with init.lua.
        for name in &["pkg_a", "pkg_b"] {
            let dir = root.join(name);
            std::fs::create_dir(&dir).unwrap();
            std::fs::write(dir.join("init.lua"), "return {}").unwrap();
        }
        // hub_index.json intentionally absent.

        let mut buckets = DoctorBuckets::default();
        run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");

        assert_eq!(
            buckets.missing_hub_index.len(),
            1,
            "expected 1 missing_hub_index entry: {:?}",
            buckets.missing_hub_index
        );
        let entry = &buckets.missing_hub_index[0];
        assert_eq!(entry["kind"], "missing_hub_index");
        assert_eq!(entry["pkg_count"], 2);
        assert!(
            entry["suggestion"]
                .as_str()
                .unwrap_or("")
                .contains("alc_hub_reindex"),
            "suggestion: {entry}"
        );
    }

    /// T2 (boundary): only one package dir with init.lua — heuristic requires
    /// 2+, so no entry is emitted.
    #[test]
    fn run_hub_index_pass_skips_when_only_1_pkg_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let dir = root.join("pkg_a");
        std::fs::create_dir(&dir).unwrap();
        std::fs::write(dir.join("init.lua"), "return {}").unwrap();
        // No hub_index.json — but only 1 pkg dir, so must NOT fire.

        let mut buckets = DoctorBuckets::default();
        run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");

        assert!(
            buckets.missing_hub_index.is_empty(),
            "must not emit with only 1 pkg dir: {:?}",
            buckets.missing_hub_index
        );
    }

    /// T3 (error path guard): two package dirs but hub_index.json already
    /// exists — no entry is emitted.
    #[test]
    fn run_hub_index_pass_skips_when_hub_index_exists() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        for name in &["pkg_a", "pkg_b"] {
            let dir = root.join(name);
            std::fs::create_dir(&dir).unwrap();
            std::fs::write(dir.join("init.lua"), "return {}").unwrap();
        }
        // hub_index.json present.
        std::fs::write(root.join("hub_index.json"), "{}").unwrap();

        let mut buckets = DoctorBuckets::default();
        run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");

        assert!(
            buckets.missing_hub_index.is_empty(),
            "must not emit when hub_index.json exists: {:?}",
            buckets.missing_hub_index
        );
    }

    #[test]
    fn installed_missing_suggestion_shape() {
        let git = PackageSource::Git {
            url: "github.com/foo/bar".to_string(),
            rev: None,
        };
        let s = installed_missing_suggestion("ucb", &git);
        assert!(s.contains("alc_pkg_install"), "{s}");
        assert!(s.contains("\"ucb\""), "{s}");
        assert!(s.contains("github.com/foo/bar"), "{s}");
    }

    /// A bundled-source entry must route the user to `alc_init`, NOT
    /// `alc_pkg_install("bundled")` (which would fail — bundled packages
    /// ship inside the algocline binary and are restored via `alc_init`).
    /// Mirrors `repair.rs` bundled arm.
    #[test]
    fn installed_missing_suggestion_routes_bundled_to_alc_init() {
        let bundled = PackageSource::Bundled { collection: None };
        let s = installed_missing_suggestion("ucb", &bundled);
        assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
        assert!(
            !s.contains("alc_pkg_install"),
            "bundled must NOT suggest alc_pkg_install: {s}"
        );
    }

    /// A `Path` source entry emits a suggestion pointing at
    /// `alc_pkg_install(<path>)` — matching repair's LocalPath installer
    /// route. (Under the typed migration, `alc_pkg_install` now records
    /// local installs as `Path { path }` rather than the legacy
    /// `Installed` coercion, so this is the canonical local-reinstall
    /// suggestion.)
    #[test]
    fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
        let local = PackageSource::Path {
            path: "/abs/path/to/src".to_string(),
        };
        let s = installed_missing_suggestion("local_pkg", &local);
        assert!(s.contains("alc_pkg_install"), "{s}");
        assert!(s.contains("/abs/path/to/src"), "{s}");
    }

    /// `Unknown` source (legacy pre-typed entry with no recorded source)
    /// must route the user to `alc_hub_reindex` before attempting a
    /// reinstall — mirrors the `Unrepairable` routing in `repair.rs`.
    #[test]
    fn installed_missing_suggestion_routes_unknown_to_reindex() {
        let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
        assert!(
            s.contains("alc_hub_reindex"),
            "Unknown must suggest alc_hub_reindex: {s}"
        );
    }

    // ── extract_required_subs ────────────────────────────────────────────

    #[test]
    fn extract_subs_double_quote() {
        let src = r#"
local M = {}
local check = require("mypkg.check")
local t = require("mypkg.t")
return M
"#;
        let subs = extract_required_subs(src, "mypkg");
        assert_eq!(subs, vec!["check", "t"]);
    }

    #[test]
    fn extract_subs_single_quote() {
        let src = "local x = require('mypkg.sub')";
        let subs = extract_required_subs(src, "mypkg");
        assert_eq!(subs, vec!["sub"]);
    }

    #[test]
    fn extract_subs_ignores_other_packages() {
        let src = r#"
local x = require("other.sub")
local y = require("mypkg.mine")
"#;
        let subs = extract_required_subs(src, "mypkg");
        assert_eq!(subs, vec!["mine"]);
    }

    #[test]
    fn extract_subs_deduplicates() {
        let src = r#"
local a = require("mypkg.check")
local b = require("mypkg.check")
"#;
        let subs = extract_required_subs(src, "mypkg");
        assert_eq!(subs, vec!["check"]);
    }

    #[test]
    fn extract_subs_ignores_dynamic_require() {
        // Dynamic require (no parenthesised string literal) must not be detected.
        let src = r#"local x = require(mod_name)"#;
        let subs = extract_required_subs(src, "mypkg");
        assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
    }

    #[test]
    fn extract_subs_ignores_nested_dots() {
        // Only direct children: `pkg.sub`, not `pkg.sub.deeper`.
        let src = r#"local x = require("mypkg.sub.deeper")"#;
        let subs = extract_required_subs(src, "mypkg");
        assert!(
            subs.is_empty(),
            "nested dotted require must be ignored: {subs:?}"
        );
    }

    #[test]
    fn extract_subs_empty_for_no_require() {
        let src = r#"local M = {} return M"#;
        let subs = extract_required_subs(src, "mypkg");
        assert!(subs.is_empty());
    }

    // ── check_incomplete ─────────────────────────────────────────────────

    #[test]
    fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
        let tmp = tempfile::tempdir().unwrap();
        let dest = tmp.path().join("mypkg");
        std::fs::create_dir(&dest).unwrap();
        std::fs::write(
            dest.join("init.lua"),
            r#"local c = require("mypkg.check") return {}"#,
        )
        .unwrap();
        std::fs::write(dest.join("check.lua"), "return {}").unwrap();

        assert!(check_incomplete("mypkg", &dest, false).is_none());
    }

    #[test]
    fn check_incomplete_returns_none_when_sub_is_dir_init() {
        let tmp = tempfile::tempdir().unwrap();
        let dest = tmp.path().join("mypkg");
        std::fs::create_dir(&dest).unwrap();
        std::fs::write(
            dest.join("init.lua"),
            r#"local c = require("mypkg.sub") return {}"#,
        )
        .unwrap();
        // sub/ directory with init.lua
        std::fs::create_dir(dest.join("sub")).unwrap();
        std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();

        assert!(check_incomplete("mypkg", &dest, false).is_none());
    }

    #[test]
    fn check_incomplete_detects_missing_sub() {
        let tmp = tempfile::tempdir().unwrap();
        let dest = tmp.path().join("mypkg");
        std::fs::create_dir(&dest).unwrap();
        std::fs::write(
            dest.join("init.lua"),
            r#"
local check = require("mypkg.check")
local t = require("mypkg.t")
return {}
"#,
        )
        .unwrap();
        // only `check.lua` present, `t.lua` missing
        std::fs::write(dest.join("check.lua"), "return {}").unwrap();

        let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
        match outcome {
            DoctorOutcome::IncompletePkg {
                missing_subs,
                suggestion,
            } => {
                assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
                assert!(
                    suggestion.contains("alc_pkg_install"),
                    "non-symlink suggestion: {suggestion}"
                );
            }
            _ => panic!("expected IncompletePkg"),
        }
    }

    #[test]
    fn check_incomplete_suggestion_uses_link_for_symlink() {
        let tmp = tempfile::tempdir().unwrap();
        let dest = tmp.path().join("mypkg");
        std::fs::create_dir(&dest).unwrap();
        std::fs::write(
            dest.join("init.lua"),
            r#"local x = require("mypkg.missing") return {}"#,
        )
        .unwrap();
        // `missing.lua` absent

        let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
        match outcome {
            DoctorOutcome::IncompletePkg { suggestion, .. } => {
                assert!(
                    suggestion.contains("alc_pkg_link"),
                    "symlink suggestion: {suggestion}"
                );
            }
            _ => panic!("expected IncompletePkg"),
        }
    }

    #[test]
    fn check_incomplete_returns_none_when_no_init_lua() {
        // Package with no init.lua at all — best-effort skip, returns None.
        let tmp = tempfile::tempdir().unwrap();
        let dest = tmp.path().join("mypkg");
        std::fs::create_dir(&dest).unwrap();

        assert!(check_incomplete("mypkg", &dest, false).is_none());
    }

    #[test]
    fn classify_installed_incomplete_pkg() {
        // classify_installed should return IncompletePkg when sub.lua is missing.
        let tmp = tempfile::tempdir().unwrap();
        let pkg_dir = tmp.path();
        let dest = pkg_dir.join("mypkg");
        std::fs::create_dir(&dest).unwrap();
        std::fs::write(
            dest.join("init.lua"),
            r#"local x = require("mypkg.sub") return {}"#,
        )
        .unwrap();
        // sub.lua intentionally absent

        let outcome =
            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
        match outcome {
            DoctorOutcome::IncompletePkg {
                missing_subs,
                suggestion,
            } => {
                assert_eq!(missing_subs, vec!["sub"]);
                assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
            }
            _ => panic!("expected IncompletePkg, got {outcome:?}"),
        }
    }

    #[test]
    fn classify_installed_healthy_when_all_subs_present() {
        // classify_installed should return Healthy when all required subs exist
        // and M.meta is declared — no explicit type needed; UnmarkedLibrary is gone.
        let tmp = tempfile::tempdir().unwrap();
        let pkg_dir = tmp.path();
        let dest = pkg_dir.join("mypkg");
        std::fs::create_dir(&dest).unwrap();
        std::fs::write(
            dest.join("init.lua"),
            "local M = {}\n\
             M.meta = { name = \"mypkg\", version = \"0.1.0\" }\n\
             local x = require(\"mypkg.sub\")\n\
             return M",
        )
        .unwrap();
        std::fs::write(dest.join("sub.lua"), "return {}").unwrap();

        let outcome =
            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
        assert!(
            matches!(outcome, DoctorOutcome::Healthy),
            "expected Healthy, got {outcome:?}"
        );
    }

    // ── run_alive_unregistered_symlink_pass ───────────────────────────────

    /// T-alive-1: an alive symlink whose target contains an `init.lua` and is
    /// not registered in any source is pushed to `buckets.unregistered_pkg`.
    /// UnmarkedLibrary bucket is gone — all alive unregistered symlinks route
    /// to unregistered_pkg regardless of type (library or runnable).
    #[cfg(unix)]
    #[tokio::test]
    async fn run_alive_unregistered_symlink_pass_unregistered_pkg() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().to_path_buf();

        // fixture: root/packages/mypkg → root/real_pkg
        let pkg_dir = root.join("packages");
        std::fs::create_dir_all(&pkg_dir).unwrap();

        // Real package directory (symlink target).
        let real = root.join("real_pkg");
        std::fs::create_dir(&real).unwrap();
        std::fs::write(
            real.join("init.lua"),
            "local M = {}\n\
             M.meta = { name = \"mypkg\", version = \"0.1.0\" }\n\
             return M",
        )
        .unwrap();

        // Alive symlink: packages/mypkg → real_pkg.
        let link = pkg_dir.join("mypkg");
        std::os::unix::fs::symlink(&real, &link).unwrap();

        let app_service = make_app_service_at(root).await;
        let registered = HashSet::new();
        let registered_paths: Vec<PathBuf> = vec![];
        let mut buckets = DoctorBuckets::default();
        app_service
            .run_alive_unregistered_symlink_pass(
                &pkg_dir,
                &registered,
                &registered_paths,
                None,
                &mut buckets,
            )
            .await
            .expect("pass ok");

        assert_eq!(
            buckets.unregistered_pkg.len(),
            1,
            "expected 1 unregistered_pkg entry, got {:?}",
            buckets.unregistered_pkg
        );
        let entry = &buckets.unregistered_pkg[0];
        assert_eq!(entry["name"], "mypkg");
        assert_eq!(entry["kind"], "unregistered_pkg");
        assert_eq!(entry["source"], "unknown");
        assert!(
            entry["reason"]
                .as_str()
                .unwrap_or("")
                .contains("alive symlink"),
            "reason must mention alive symlink: {:?}",
            entry["reason"]
        );
        let suggestion = entry["suggestion"].as_array().expect("suggestion is array");
        assert_eq!(suggestion.len(), 4, "suggestion must have 4 elements");
    }
}
