//! Composer autoload support.
//!
//! This module handles parsing `composer.json` to extract PSR-4 autoload
//! mappings, and resolving fully-qualified PHP class names to file paths
//! on disk using those mappings.
//!
//! It also parses `autoload_files.php` (generated by Composer) to discover
//! files that contain global function and constant definitions.
//!
//! # PSR-4 Resolution
//!
//! Given a mapping like `"Klarna\\" => "src/Klarna/"`, a class name like
//! `Klarna\Customer` is resolved by:
//!   1. Stripping the matching prefix (`Klarna\`) from the class name
//!   2. Converting remaining namespace separators to directory separators
//!   3. Appending `.php`
//!   4. Prepending the mapped base directory
//!
//! Result: `<workspace>/src/Klarna/Customer.php`
//!
//! Composer JSON parsing is delegated to the [`mago_composer`] crate,
//! which provides typed Rust structs for the full `composer.json` schema.

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

pub use mago_composer::ComposerPackage;
use mago_composer::ComposerPackageExtra;

use crate::types::PhpVersion;

/// A single PSR-4 namespace-to-directory mapping.
///
/// PSR-4 mappings are sourced exclusively from the project's own
/// `composer.json` (`autoload` and `autoload-dev` sections).  Vendor
/// packages are not included — the Composer classmap is the sole source
/// of truth for vendor code.
#[derive(Debug, Clone)]
pub struct Psr4Mapping {
    /// The namespace prefix, always ending with `\` (e.g. `"Klarna\"`).
    pub prefix: String,
    /// The base directory path relative to the workspace root (e.g. `"src/Klarna/"`).
    pub base_path: String,
}

/// Parse a `composer.json` file at the given workspace root and extract all
/// PSR-4 autoload mappings from both `autoload` and `autoload-dev` sections.
///
/// Only the project's own mappings are returned.  Vendor package mappings
/// (from `vendor/composer/autoload_psr4.php`) are deliberately not loaded —
/// the Composer classmap is the sole source of truth for vendor code.  If the
/// classmap is missing or stale, vendor classes will fail to resolve visibly
/// instead of being silently papered over by PSR-4, making the problem
/// obvious to the user (fix: run `composer dump-autoload`).
///
/// Returns `(mappings, vendor_dir)` where `vendor_dir` is the configured
/// `config.vendor-dir` (defaulting to `"vendor"`).  Both values come from
/// the same parse of `composer.json` so the file is only read once.
///
/// Returns an empty `Vec` and `"vendor"` if the file doesn't exist, can't
/// be read, or contains no PSR-4 mappings.
pub fn parse_composer_json(workspace_root: &Path) -> (Vec<Psr4Mapping>, String) {
    let package = match read_composer_package(workspace_root) {
        Some(p) => p,
        None => return (Vec::new(), "vendor".to_string()),
    };

    let vendor_dir = get_vendor_dir(&package);
    let mappings = extract_psr4_mappings_from_package(&package);

    (mappings, vendor_dir)
}

/// Read and parse the `composer.json` at the given workspace root into a
/// typed [`ComposerPackage`].
///
/// Returns `None` if the file does not exist, cannot be read, or fails
/// to parse.
pub fn read_composer_package(workspace_root: &Path) -> Option<ComposerPackage> {
    let composer_path = workspace_root.join("composer.json");
    let content = fs::read_to_string(&composer_path).ok()?;
    content.parse::<ComposerPackage>().ok()
}

/// Extract all PSR-4 autoload mappings from both `autoload` and
/// `autoload-dev` sections of a parsed [`ComposerPackage`].
///
/// Returns the mappings sorted by prefix length descending so that
/// longest-prefix-first matching works correctly.
pub fn extract_psr4_mappings_from_package(package: &ComposerPackage) -> Vec<Psr4Mapping> {
    let mut mappings = Vec::new();

    // Extract from "autoload" section
    if let Some(autoload) = &package.autoload {
        for (prefix, paths) in &autoload.psr_4 {
            collect_psr4_entries(prefix, paths, &mut mappings);
        }
    }

    // Extract from "autoload-dev" section
    if let Some(autoload_dev) = &package.autoload_dev {
        for (prefix, paths) in &autoload_dev.psr_4 {
            collect_psr4_entries(prefix, paths, &mut mappings);
        }
    }

    // Sort by prefix length descending so longest-prefix-first matching works
    mappings.sort_by_key(|b| std::cmp::Reverse(b.prefix.len()));

    mappings
}

/// Directories that `build_self_scan_composer` needs from a parsed
/// `composer.json`.  Returned by [`extract_scan_dirs`].
pub struct ScanDirs {
    /// `(normalised_prefix, directory_path_string)` pairs from both
    /// `autoload.psr-4` and `autoload-dev.psr-4`.
    pub psr4: Vec<(String, String)>,
    /// Directory/file paths from both `autoload.classmap` and
    /// `autoload-dev.classmap`.
    pub classmap: Vec<String>,
}

/// Extract the PSR-4 and classmap directory lists from a
/// [`ComposerPackage`] for workspace scanning.
///
/// This keeps mago types private to this module — callers receive
/// plain strings and don't need to depend on `mago_composer`.
pub fn extract_scan_dirs(package: &ComposerPackage) -> ScanDirs {
    let mut psr4 = Vec::new();
    let mut classmap = Vec::new();

    if let Some(autoload) = &package.autoload {
        for (prefix, paths) in &autoload.psr_4 {
            let normalised = normalise_prefix(prefix);
            paths.for_each_path(|dir| {
                psr4.push((normalised.clone(), dir.to_owned()));
            });
        }
        classmap.extend(autoload.classmap.iter().cloned());
    }

    if let Some(autoload_dev) = &package.autoload_dev {
        for (prefix, paths) in &autoload_dev.psr_4 {
            let normalised = normalise_prefix(prefix);
            paths.for_each_path(|dir| {
                psr4.push((normalised.clone(), dir.to_owned()));
            });
        }
        classmap.extend(autoload_dev.classmap.iter().cloned());
    }

    ScanDirs { psr4, classmap }
}

/// Read the configured vendor directory from a parsed [`ComposerPackage`].
///
/// Looks at `config.vendor-dir`; defaults to `"vendor"` when absent.
pub(crate) fn get_vendor_dir(package: &ComposerPackage) -> String {
    package
        .config
        .as_ref()
        .and_then(|c| c.vendor_dir.as_deref())
        .map(|s| s.trim_end_matches('/').to_string())
        .unwrap_or_else(|| "vendor".to_string())
}

/// Read the configured bin directory from a parsed [`ComposerPackage`].
///
/// Looks at `config.bin-dir`; defaults to `"<vendor-dir>/bin"` when absent,
/// where `<vendor-dir>` is itself resolved via [`get_vendor_dir`].
pub(crate) fn get_bin_dir(package: &ComposerPackage) -> String {
    if let Some(explicit) = package.config.as_ref().and_then(|c| c.bin_dir.as_deref()) {
        explicit.trim_end_matches('/').to_string()
    } else {
        format!("{}/bin", get_vendor_dir(package))
    }
}

/// Parse `<vendor>/composer/autoload_classmap.php` and return a mapping
/// from fully-qualified class name to file path (relative to the workspace
/// root).
///
/// This file is generated by `composer install` / `composer dump-autoload`
/// and maps class names directly to their defining files.  When the user
/// runs `composer install -o` (optimised autoloader), Composer converts
/// all PSR-0 and PSR-4 mappings into a classmap, giving us complete
/// coverage of every loadable class.
///
/// The file contains lines like:
/// ```text
///     'AWS\\CRT\\Auth\\AwsCredentials' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/AwsCredentials.php',
///     'App\\Models\\User' => $baseDir . '/app/Models/User.php',
/// ```
///
/// `$vendorDir` is resolved relative to the workspace root using the
/// configured vendor directory.  `$baseDir` is the workspace root itself.
///
/// Returns an empty `HashMap` if the file does not exist or cannot be
/// parsed.
pub fn parse_autoload_classmap(
    workspace_root: &Path,
    vendor_dir: &str,
) -> HashMap<String, PathBuf> {
    let autoload_path = workspace_root
        .join(vendor_dir)
        .join("composer")
        .join("autoload_classmap.php");

    let content = match fs::read_to_string(&autoload_path) {
        Ok(c) => c,
        Err(_) => return HashMap::new(),
    };

    let mut classmap = HashMap::new();

    for line in content.lines() {
        let trimmed = line.trim();

        // Match lines of the form:
        //   'Fully\\Qualified\\ClassName' => $vendorDir . '/path/to/File.php',
        //   'Fully\\Qualified\\ClassName' => $baseDir . '/path/to/File.php',
        if let Some(rest) = trimmed.strip_prefix('\'')
            && let Some(arrow_pos) = rest.find("' => ")
        {
            // Unescape PHP single-quoted string escapes:
            //   \\  →  \
            //   \'  →  '
            let class_name = rest[..arrow_pos]
                .replace("\\\\'", "'")
                .replace("\\\\", "\\");

            let rhs = rest[arrow_pos + "' => ".len()..]
                .trim()
                .trim_end_matches(',');

            if let Some(relative_path) = resolve_autoload_path_entry(rhs, vendor_dir) {
                classmap.insert(class_name, workspace_root.join(&relative_path));
            }
        }
    }

    classmap
}

/// Resolve a single path entry from `autoload_psr4.php`.
///
/// Handles `$vendorDir . '/path'` and `$baseDir . '/path'`.
fn resolve_autoload_path_entry(entry: &str, vendor_dir: &str) -> Option<String> {
    let entry = entry.trim();

    if let Some(rest) = entry.strip_prefix("$vendorDir . '") {
        // $vendorDir . '/org/pkg/src'
        let path = rest.strip_suffix('\'')?;
        let path = path.strip_prefix('/').unwrap_or(path);
        Some(format!("{}/{}", vendor_dir, path))
    } else if let Some(rest) = entry.strip_prefix("$baseDir . '") {
        // $baseDir . '/lib'
        let path = rest.strip_suffix('\'')?;
        let path = path.strip_prefix('/').unwrap_or(path);
        Some(path.to_string())
    } else {
        None
    }
}

/// Parse `<vendor>/composer/autoload_files.php` and return the resolved
/// file paths.
///
/// This file is generated by `composer install` / `composer dump-autoload`
/// and lists files that should be eagerly loaded — typically containing
/// global function definitions, `define()` calls, and similar bootstrap
/// code.
///
/// The file contains lines like:
/// ```text
///     'hash' => $vendorDir . '/org/pkg/src/functions.php',
///     'hash' => $baseDir . '/app/Http/helpers.php',
/// ```
///
/// `$vendorDir` is resolved relative to the workspace root using the
/// configured vendor directory.  `$baseDir` is the workspace root itself.
///
/// Returns an empty `Vec` if the file does not exist or cannot be parsed.
pub fn parse_autoload_files(workspace_root: &Path, vendor_dir: &str) -> Vec<PathBuf> {
    let autoload_path = workspace_root
        .join(vendor_dir)
        .join("composer")
        .join("autoload_files.php");

    let content = match fs::read_to_string(&autoload_path) {
        Ok(c) => c,
        Err(_) => return Vec::new(),
    };

    let mut files = Vec::new();

    for line in content.lines() {
        let trimmed = line.trim();

        // Match lines of the form:  'hash' => $vendorDir . '/path/to/file.php',
        //                       or: 'hash' => $baseDir . '/path/to/file.php',
        // We look for `=> $vendorDir` or `=> $baseDir` after the hash key.
        if let Some(arrow_pos) = trimmed.find("=> ") {
            let rhs = trimmed[arrow_pos + 3..].trim().trim_end_matches(',');

            if let Some(base_path) = resolve_autoload_path_entry(rhs, vendor_dir) {
                let full_path = workspace_root.join(&base_path);
                if full_path.is_file() {
                    files.push(full_path);
                }
            }
        }
    }

    files
}

// ── PSR-4 path abstraction ─────────────────────────────────────────
//
// `mago-composer` emits two structurally identical but nominally
// distinct enums for `autoload.psr-4` and `autoload-dev.psr-4`.
// The trait + macro below erase the difference so a single generic
// function handles both.

/// Uniform access to the path strings inside a PSR-4 value, regardless
/// of whether it came from `autoload` or `autoload-dev`.
trait Psr4Paths {
    /// Call `f` for each directory path in this value.
    fn for_each_path(&self, f: impl FnMut(&str));
}

macro_rules! impl_psr4_paths {
    ($ty:ty) => {
        impl Psr4Paths for $ty {
            fn for_each_path(&self, mut f: impl FnMut(&str)) {
                match self {
                    Self::String(s) => f(s),
                    Self::Array(arr) => arr.iter().for_each(|s| f(s)),
                }
            }
        }
    };
}

impl_psr4_paths!(mago_composer::AutoloadPsr4value);
impl_psr4_paths!(mago_composer::ComposerPackageAutoloadDevPsr4value);

/// Normalise a PSR-4 namespace prefix: ensure it ends with `\` unless
/// it is the empty root-namespace prefix.
fn normalise_prefix(prefix: &str) -> String {
    if prefix.ends_with('\\') {
        prefix.to_string()
    } else if prefix.is_empty() {
        // Empty prefix means "fallback" / root namespace
        String::new()
    } else {
        format!("{}\\", prefix)
    }
}

/// Collect PSR-4 entries from any value that implements [`Psr4Paths`].
fn collect_psr4_entries<P: Psr4Paths>(prefix: &str, paths: &P, mappings: &mut Vec<Psr4Mapping>) {
    let normalised_prefix = normalise_prefix(prefix);
    paths.for_each_path(|path| {
        mappings.push(Psr4Mapping {
            prefix: normalised_prefix.clone(),
            base_path: normalise_path(path),
        });
    });
}

/// Normalise a directory path: ensure it uses forward slashes and ends with `/`.
pub fn normalise_path(path: &str) -> String {
    let p = path.replace('\\', "/");
    if p.ends_with('/') || p.is_empty() {
        p
    } else {
        format!("{}/", p)
    }
}

/// Resolve a fully-qualified PHP class name to a file path using PSR-4 mappings.
///
/// The `class_name` should be the namespace-qualified name (e.g.
/// `"Klarna\\Customer"` or `"Klarna\\Rest\\Order"`). A leading `\` is
/// stripped if present (PHP fully-qualified syntax).
///
/// Returns the first path that exists on disk, or `None` if no mapping
/// matches or the resolved file doesn't exist.
pub fn resolve_class_path(
    mappings: &[Psr4Mapping],
    workspace_root: &Path,
    class_name: &str,
) -> Option<PathBuf> {
    let name = class_name;

    // Skip built-in type keywords that are never real classes
    if crate::php_type::is_keyword_type(name) {
        return None;
    }

    // Try each mapping (already sorted longest-prefix-first)
    for mapping in mappings {
        let relative = if mapping.prefix.is_empty() {
            // Empty prefix matches everything (root namespace fallback)
            Some(name)
        } else {
            name.strip_prefix(&mapping.prefix)
        };

        if let Some(relative_class) = relative {
            // Convert namespace separators to directory separators
            let relative_path = relative_class.replace('\\', "/");
            let file_path = workspace_root
                .join(&mapping.base_path)
                .join(format!("{}.php", relative_path));

            if file_path.is_file() {
                return Some(file_path);
            }
        }
    }

    None
}

/// Extract file paths from `require_once` statements in PHP source content.
///
/// Handles both the statement form and the function-like form:
/// ```text
///     require_once 'Trustly/exceptions.php';
///     require_once('Trustly/Data/data.php');
/// ```
///
/// Only bare string literals are supported — concatenations, variables,
/// and other dynamic expressions are silently skipped.
///
/// Returns the raw path strings exactly as written in the source (e.g.
/// `"Trustly/exceptions.php"`).  The caller is responsible for resolving
/// them relative to the file's directory.
pub fn extract_require_once_paths(content: &str) -> Vec<String> {
    let mut paths = Vec::new();

    for line in content.lines() {
        let trimmed = line.trim();

        // Quick reject: line must start with `require_once`.
        // (We don't support `require_once` buried in complex expressions.)
        if !trimmed.starts_with("require_once") {
            continue;
        }

        let rest = trimmed["require_once".len()..].trim_start();

        // Strip optional parentheses: `require_once('...')` → `'...'`
        // Also handles `require_once '...'` without parens.
        let rest = if let Some(inner) = rest.strip_prefix('(') {
            // Find matching closing paren
            if let Some(end) = inner.rfind(')') {
                inner[..end].trim()
            } else {
                continue;
            }
        } else {
            rest
        };

        // Strip trailing semicolon
        let rest = rest.trim_end_matches(';').trim();

        // Extract string literal — single or double quoted
        let path = if (rest.starts_with('\'') && rest.ends_with('\''))
            || (rest.starts_with('"') && rest.ends_with('"'))
        {
            &rest[1..rest.len() - 1]
        } else {
            // Not a simple string literal — skip
            continue;
        };

        if !path.is_empty() {
            paths.push(path.to_string());
        }
    }

    paths
}

/// Parse `<vendor>/composer/autoload_namespaces.php` (PSR-0 map) and
/// scan the listed directories for PHP classes.
///
/// PSR-0 is a legacy autoloading standard where underscores in class
/// names map to directory separators.  For example, the prefix
/// `HTMLPurifier` with base path `library/` means that the class
/// `HTMLPurifier_Config` lives at `library/HTMLPurifier/Config.php`.
///
/// Composer generates `autoload_namespaces.php` for packages that
/// declare `autoload.psr-0` in their `composer.json`.  The file
/// contains lines like:
///
/// ```text
///     'HTMLPurifier' => array($vendorDir . '/ezyang/htmlpurifier/library'),
/// ```
///
/// Rather than implementing PSR-0 name-to-path resolution (which would
/// require handling underscore splitting, prefix directories, and
/// fallback rules), we simply scan each listed directory with the
/// byte-level class scanner and return a classmap.  This gives us the
/// same result as `composer install -o` for PSR-0 packages without
/// requiring the user to run the optimised dump.
///
/// Returns an empty `HashMap` if the file does not exist or has no
/// entries.
pub fn parse_autoload_namespaces(
    workspace_root: &Path,
    vendor_dir: &str,
) -> HashMap<String, PathBuf> {
    let autoload_path = workspace_root
        .join(vendor_dir)
        .join("composer")
        .join("autoload_namespaces.php");

    let content = match fs::read_to_string(&autoload_path) {
        Ok(c) => c,
        Err(_) => return HashMap::new(),
    };

    // Collect all base directories listed in the PSR-0 map.
    // Each line looks like:
    //   'Prefix' => array($vendorDir . '/org/pkg/src'),
    //   'Prefix' => array($vendorDir . '/org/pkg/src', $baseDir . '/lib'),
    let mut base_dirs: Vec<PathBuf> = Vec::new();

    for line in content.lines() {
        let trimmed = line.trim();

        // Skip lines that don't look like array entries.
        if !trimmed.contains("=>") {
            continue;
        }

        // Extract everything after `=> array(` or `=> [`
        let rhs = if let Some(pos) = trimmed.find("=> array(") {
            &trimmed[pos + "=> array(".len()..]
        } else if let Some(pos) = trimmed.find("=> [") {
            &trimmed[pos + "=> [".len()..]
        } else {
            continue;
        };

        // Strip trailing `)`, `],`, etc.
        let rhs =
            rhs.trim_end_matches(|c: char| c == ')' || c == ']' || c == ',' || c.is_whitespace());

        // Split by comma to handle multiple paths per prefix.
        for segment in rhs.split(',') {
            let segment = segment.trim();
            if let Some(relative_path) = resolve_autoload_path_entry(segment, vendor_dir) {
                let full_path = workspace_root.join(&relative_path);
                if full_path.is_dir() {
                    base_dirs.push(full_path);
                }
            }
        }
    }

    // Scan each base directory for PHP class files.
    let mut classmap = HashMap::new();

    for base_dir in &base_dirs {
        scan_directory_for_classes(base_dir, &mut classmap);
    }

    classmap
}

/// Recursively scan a directory for PHP files and extract class names
/// using the lightweight byte-level scanner.
fn scan_directory_for_classes(dir: &Path, classmap: &mut HashMap<String, PathBuf>) {
    let entries = match fs::read_dir(dir) {
        Ok(e) => e,
        Err(_) => return,
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            scan_directory_for_classes(&path, classmap);
        } else if path.extension().is_some_and(|ext| ext == "php")
            && let Ok(content) = fs::read(&path)
        {
            let classes = crate::classmap_scanner::find_classes(&content);
            for fqn in classes {
                classmap.entry(fqn).or_insert_with(|| path.clone());
            }
        }
    }
}

/// Detect `.phar` archive references in a PHP bootstrap file.
///
/// Scans the file content for patterns like:
///
/// ```text
/// require 'phar://' . __DIR__ . '/phpstan.phar/vendor/autoload.php';
/// $filepath = 'phar://' . __DIR__ . '/phpstan.phar/src/' . $filename . '.php';
/// ```
///
/// Returns the absolute paths of all `.phar` files referenced in the
/// content, resolved relative to `file_dir` (the directory containing
/// the bootstrap file).
///
/// This is intentionally lenient: it looks for any `__DIR__` + string
/// concatenation that mentions a `.phar` file, regardless of the
/// surrounding PHP structure.  False positives are harmless (the phar
/// parser will reject non-phar files), and false negatives are
/// acceptable (users can always extract the phar manually).
pub fn detect_phar_references(content: &str, file_dir: &Path) -> Vec<PathBuf> {
    let mut phars = Vec::new();
    let mut seen = std::collections::HashSet::new();

    for line in content.lines() {
        // Look for lines containing both `__DIR__` and `.phar`.
        if !line.contains("__DIR__") || !line.contains(".phar") {
            continue;
        }

        // Extract the phar filename from string fragments like:
        //   '/phpstan.phar/vendor/autoload.php'
        //   '/phpstan.phar/src/'
        // We look for quoted strings containing `.phar` and extract
        // the path up to (and including) the `.phar` extension.
        for quote in [b'\'', b'"'] {
            let bytes = line.as_bytes();
            let mut i = 0;
            while i < bytes.len() {
                if bytes[i] == quote {
                    // Find the closing quote.
                    if let Some(end) = bytes[i + 1..].iter().position(|&b| b == quote) {
                        let fragment = &line[i + 1..i + 1 + end];
                        if let Some(phar_end) = fragment.find(".phar") {
                            // Extract the relative path: e.g. "/phpstan.phar"
                            let rel = &fragment[..phar_end + 5]; // +5 for ".phar"
                            let rel = rel.trim_start_matches('/');
                            if !rel.is_empty() {
                                let phar_path = file_dir.join(rel);
                                if phar_path.is_file() && seen.insert(phar_path.clone()) {
                                    phars.push(phar_path);
                                }
                            }
                        }
                        i += 1 + end + 1;
                    } else {
                        i += 1;
                    }
                } else {
                    i += 1;
                }
            }
        }
    }

    phars
}

/// Discover PHP subproject roots inside a workspace directory.
///
/// When the workspace root itself has no `composer.json`, this function
/// walks subdirectories looking for `composer.json` files using the
/// `ignore` crate for gitignore-aware walking.  Each directory that
/// contains a `composer.json` is returned as a subproject root paired
/// with its configured vendor directory name.  Once a `composer.json`
/// is found in a directory, its children are not descended into (they
/// belong to that project's source tree, not to a separate subproject).
///
/// The walk respects `.gitignore` at every level, so vendor directories
/// that are gitignored (the common case in monorepos) are automatically
/// skipped along with any other noise the user has excluded.  Hidden
/// directories (starting with `.`) are also skipped.
///
/// This enables monorepo support: a workspace like
///
/// ```text
/// monorepo/               ← workspace root (no composer.json)
/// ├── project-a/
/// │   ├── composer.json   ← discovered
/// │   ├── src/
/// │   └── vendor/
/// └── packages/
///     └── project-b/
///         ├── composer.json  ← discovered (depth 2)
///         └── src/
/// ```
///
/// returns `[(monorepo/project-a, "vendor"), (monorepo/packages/project-b, "vendor")]`.
pub fn discover_subproject_roots(workspace_root: &Path) -> Vec<(PathBuf, String)> {
    use ignore::WalkBuilder;
    use std::collections::HashSet;

    let mut candidates: Vec<PathBuf> = Vec::new();

    let walker = WalkBuilder::new(workspace_root)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true)
        .hidden(true)
        .parents(true)
        .ignore(true)
        // Sort to get deterministic output
        .sort_by_file_name(|a, b| a.cmp(b))
        .build();

    for entry in walker.flatten() {
        let path = entry.path();
        // We only care about files named "composer.json"
        if !path.is_file() {
            continue;
        }
        if path.file_name().and_then(|n| n.to_str()) != Some("composer.json") {
            continue;
        }
        // The subproject root is the parent directory of composer.json
        if let Some(parent) = path.parent() {
            // Skip the workspace root itself — the caller already checked
            // that it has no composer.json (or has one and took the
            // single-project path).
            if parent == workspace_root {
                continue;
            }
            candidates.push(parent.to_path_buf());
        }
    }

    // Filter out nested roots: if both `packages/foo` and
    // `packages/foo/subdir` contain composer.json, only keep the
    // outermost one.  Sort by path length so we process shorter
    // (shallower) paths first.
    candidates.sort_by_key(|p| p.as_os_str().len());

    let mut roots: Vec<(PathBuf, String)> = Vec::new();
    let mut root_set: HashSet<PathBuf> = HashSet::new();

    for candidate in &candidates {
        // Check if this candidate is inside an already-accepted root
        let is_nested = root_set.iter().any(|root| candidate.starts_with(root));
        if is_nested {
            continue;
        }

        // Read the vendor dir name from this subproject's composer.json
        let vendor_dir = read_composer_package(candidate)
            .map(|pkg| get_vendor_dir(&pkg))
            .unwrap_or_else(|| "vendor".to_string());

        root_set.insert(candidate.clone());
        roots.push((candidate.clone(), vendor_dir));
    }

    roots
}

/// Detect the target PHP version from a project's `composer.json`.
///
/// Checks two locations in order:
///   1. `config.platform.php` — an explicit platform override
///      (e.g. `"8.3.1"` → 8.3).
///   2. `require.php` — the version constraint from the dependency
///      list (e.g. `"^8.4"` → 8.4).
///
/// Returns `None` when `composer.json` is missing, unreadable, or does
/// not contain a PHP version constraint.  The caller should fall back to
/// [`PhpVersion::default`] in that case.
pub fn detect_php_version(workspace_root: &Path) -> Option<PhpVersion> {
    let package = read_composer_package(workspace_root)?;
    detect_php_version_from_package(&package)
}

/// Extract the target PHP version from a parsed [`ComposerPackage`].
///
/// Checks two locations in order:
///   1. `config.platform.php` — an explicit platform override
///      (e.g. `"8.3.1"` → 8.3).
///   2. `require.php` — the version constraint from the dependency
///      list (e.g. `"^8.4"` → 8.4).
///
/// Returns `None` when the package does not contain a PHP version
/// constraint.  The caller should fall back to [`PhpVersion::default`]
/// in that case.
pub fn detect_php_version_from_package(package: &ComposerPackage) -> Option<PhpVersion> {
    // 1. config.platform.php — exact version string like "8.3.1"
    if let Some(platform_val) = package.config.as_ref().and_then(|c| c.platform.get("php"))
        && let mago_composer::ComposerPackageConfigPlatformValue::String(s) = platform_val
        && let Some(ver) = PhpVersion::from_composer_constraint(s)
    {
        return Some(ver);
    }

    // 2. require.php — constraint string like "^8.4", ">=8.0"
    if let Some(require_php) = package.require.get("php")
        && let Some(ver) = PhpVersion::from_composer_constraint(require_php)
    {
        return Some(ver);
    }

    None
}

/// Check whether a package name appears in `composer.json`'s `require-dev`.
///
/// Matches the package name exactly (e.g.
/// `"friendsofphp/php-cs-fixer"`, `"squizlabs/php_codesniffer"`).
pub(crate) fn has_require_dev(package: &ComposerPackage, dep: &str) -> bool {
    package.require_dev.contains_key(dep)
}

/// Detect whether the project is a Drupal project and resolve the web root.
///
/// Returns `Some(web_root)` if one of the canonical Drupal core packages is
/// listed in `require` or `require-dev`, and we can determine the web root.
///
/// **Web-root resolution order:**
/// 1. `extra.drupal-scaffold.locations.web-root` in `composer.json`.
/// 2. Filesystem fallback: the first of `web/`, `docroot/`, `public/`,
///    `html/` that contains `core/lib/Drupal.php`.
pub(crate) fn detect_drupal_web_root(
    workspace_root: &Path,
    package: &ComposerPackage,
) -> Option<PathBuf> {
    const DRUPAL_PACKAGES: &[&str] = &["drupal/core", "drupal/core-recommended", "drupal/core-dev"];

    let is_drupal = DRUPAL_PACKAGES
        .iter()
        .any(|pkg| package.require.contains_key(*pkg) || package.require_dev.contains_key(*pkg));

    if !is_drupal {
        return None;
    }

    // 1. Read from extra.drupal-scaffold.locations.web-root
    if let Some(ComposerPackageExtra::Object(extra_map)) = &package.extra
        && let Some(scaffold) = extra_map.get("drupal-scaffold")
        && let Some(web_root_str) = scaffold
            .get("locations")
            .and_then(|l| l.get("web-root"))
            .and_then(|v| v.as_str())
    {
        let path = workspace_root.join(web_root_str.trim_end_matches('/'));
        return Some(path);
    }

    // 2. Filesystem fallback: find which common dir contains core/lib/Drupal.php
    for candidate in &["web", "docroot", "public", "html"] {
        let path = workspace_root.join(candidate);
        if path.join("core/lib/Drupal.php").exists() {
            return Some(path);
        }
    }

    None
}

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

    /// Helper: parse a JSON string into a [`ComposerPackage`].
    fn pkg(json: &str) -> ComposerPackage {
        json.parse::<ComposerPackage>().unwrap()
    }

    // ── get_vendor_dir ──────────────────────────────────────────────

    #[test]
    fn vendor_dir_default() {
        assert_eq!(get_vendor_dir(&pkg("{}")), "vendor");
    }

    #[test]
    fn vendor_dir_explicit() {
        let p = pkg(r#"{"config": {"vendor-dir": "lib"}}"#);
        assert_eq!(get_vendor_dir(&p), "lib");
    }

    #[test]
    fn vendor_dir_strips_trailing_slash() {
        let p = pkg(r#"{"config": {"vendor-dir": "lib/"}}"#);
        assert_eq!(get_vendor_dir(&p), "lib");
    }

    // ── get_bin_dir ─────────────────────────────────────────────────

    #[test]
    fn bin_dir_default_follows_vendor_dir() {
        assert_eq!(get_bin_dir(&pkg("{}")), "vendor/bin");
    }

    #[test]
    fn bin_dir_default_with_custom_vendor_dir() {
        let p = pkg(r#"{"config": {"vendor-dir": "lib"}}"#);
        assert_eq!(get_bin_dir(&p), "lib/bin");
    }

    #[test]
    fn bin_dir_explicit() {
        let p = pkg(r#"{"config": {"bin-dir": "bin"}}"#);
        assert_eq!(get_bin_dir(&p), "bin");
    }

    #[test]
    fn bin_dir_explicit_overrides_vendor_dir() {
        let p = pkg(r#"{"config": {"vendor-dir": "lib", "bin-dir": "tools/bin"}}"#);
        assert_eq!(get_bin_dir(&p), "tools/bin");
    }

    #[test]
    fn bin_dir_strips_trailing_slash() {
        let p = pkg(r#"{"config": {"bin-dir": "bin/"}}"#);
        assert_eq!(get_bin_dir(&p), "bin");
    }

    // ── detect_drupal_web_root ──────────────────────────────────────

    #[test]
    fn drupal_not_detected_without_drupal_packages() {
        let dir = tempfile::tempdir().unwrap();
        let p = pkg(r#"{"require": {"php": "^8.1", "some/package": "^1.0"}}"#);
        assert!(detect_drupal_web_root(dir.path(), &p).is_none());
    }

    #[test]
    fn drupal_detected_via_drupal_core() {
        let dir = tempfile::tempdir().unwrap();
        // Create web/core/lib/Drupal.php for the filesystem fallback
        let web = dir.path().join("web").join("core").join("lib");
        std::fs::create_dir_all(&web).unwrap();
        std::fs::write(web.join("Drupal.php"), "<?php\nclass Drupal {}").unwrap();

        let p = pkg(r#"{"require": {"drupal/core": "^10.0"}}"#);
        let result = detect_drupal_web_root(dir.path(), &p);
        assert_eq!(result, Some(dir.path().join("web")));
    }

    #[test]
    fn drupal_detected_via_core_recommended_in_require_dev() {
        let dir = tempfile::tempdir().unwrap();
        let docroot = dir.path().join("docroot").join("core").join("lib");
        std::fs::create_dir_all(&docroot).unwrap();
        std::fs::write(docroot.join("Drupal.php"), "<?php").unwrap();

        let p = pkg(r#"{"require-dev": {"drupal/core-recommended": "^10.0"}}"#);
        let result = detect_drupal_web_root(dir.path(), &p);
        assert_eq!(result, Some(dir.path().join("docroot")));
    }

    #[test]
    fn drupal_detected_via_core_dev() {
        let dir = tempfile::tempdir().unwrap();
        let web = dir.path().join("web").join("core").join("lib");
        std::fs::create_dir_all(&web).unwrap();
        std::fs::write(web.join("Drupal.php"), "<?php").unwrap();

        let p = pkg(r#"{"require-dev": {"drupal/core-dev": "^10.0"}}"#);
        let result = detect_drupal_web_root(dir.path(), &p);
        assert_eq!(result, Some(dir.path().join("web")));
    }

    #[test]
    fn drupal_web_root_from_scaffold_config() {
        let dir = tempfile::tempdir().unwrap();
        // No filesystem dirs needed — scaffold config takes priority
        let p = pkg(r#"{
            "require": {"drupal/core": "^10.0"},
            "extra": {
                "drupal-scaffold": {
                    "locations": {
                        "web-root": "docroot/"
                    }
                }
            }
        }"#);
        let result = detect_drupal_web_root(dir.path(), &p);
        // Trailing slash should be stripped
        assert_eq!(result, Some(dir.path().join("docroot")));
    }

    #[test]
    fn drupal_scaffold_config_takes_priority_over_filesystem() {
        let dir = tempfile::tempdir().unwrap();
        // Create web/core/lib/Drupal.php so filesystem fallback would find "web"
        let web = dir.path().join("web").join("core").join("lib");
        std::fs::create_dir_all(&web).unwrap();
        std::fs::write(web.join("Drupal.php"), "<?php").unwrap();

        let p = pkg(r#"{
            "require": {"drupal/core": "^10.0"},
            "extra": {
                "drupal-scaffold": {
                    "locations": {
                        "web-root": "custom_root"
                    }
                }
            }
        }"#);
        let result = detect_drupal_web_root(dir.path(), &p);
        // Scaffold config wins over filesystem fallback
        assert_eq!(result, Some(dir.path().join("custom_root")));
    }

    #[test]
    fn drupal_filesystem_fallback_tries_candidates_in_order() {
        let dir = tempfile::tempdir().unwrap();
        // Only create public/core/lib/Drupal.php (third candidate)
        let public = dir.path().join("public").join("core").join("lib");
        std::fs::create_dir_all(&public).unwrap();
        std::fs::write(public.join("Drupal.php"), "<?php").unwrap();

        let p = pkg(r#"{"require": {"drupal/core": "^10.0"}}"#);
        let result = detect_drupal_web_root(dir.path(), &p);
        assert_eq!(result, Some(dir.path().join("public")));
    }

    #[test]
    fn drupal_returns_none_when_no_web_root_found() {
        let dir = tempfile::tempdir().unwrap();
        // Drupal package present but no scaffold config and no matching dirs
        let p = pkg(r#"{"require": {"drupal/core": "^10.0"}}"#);
        assert!(detect_drupal_web_root(dir.path(), &p).is_none());
    }
}
