<?php

/*
 * This file is part of Psy Shell.
 *
 * (c) 2012-2025 Justin Hileman
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Psy\TabCompletion\AutoloadWarmer;

use Psy\Shell;

/**
 * Composer autoload warmer.
 *
 * Loads classes from Composer's autoload configuration. By default, loads
 * application classes but excludes vendor packages and test files to balance
 * startup time with completion quality.
 *
 * Example configuration:
 *
 *     // Enable autoload warming with default settings
 *     $config->setWarmAutoload(true);
 *
 *     // Disable autoload warming (default)
 *     $config->setWarmAutoload(false);
 *
 *     // Configure ComposerAutoloadWarmer options
 *     $config->setWarmAutoload([
 *         'includeVendor'     => true,                   // Include all vendor packages
 *         'includeTests'      => false,                  // Exclude test namespaces
 *         'includeNamespaces' => ['App\\', 'Lib\\'],     // Only these namespaces
 *         'excludeNamespaces' => ['App\\Legacy\\'],      // Exclude specific namespaces
 *     ]);
 *
 *     // Include specific vendor packages only
 *     $config->setWarmAutoload([
 *         'includeVendorNamespaces' => ['Symfony\\', 'Doctrine\\'],
 *     ]);
 *
 *     // Include all vendor except specific packages
 *     $config->setWarmAutoload([
 *         'includeVendor' => true,
 *         'excludeVendorNamespaces' => ['Symfony\\Debug\\'],
 *     ]);
 *
 *     // Use custom warmers only
 *     $config->setWarmAutoload([
 *         'warmers' => [$myCustomWarmer],
 *     ]);
 *
 *     // Combine custom warmers with configured ComposerAutoloadWarmer
 *     $config->setWarmAutoload([
 *         'warmers'                 => [$myCustomWarmer],
 *         'includeVendorNamespaces' => ['Symfony\\'],
 *     ]);
 */
class ComposerAutoloadWarmer implements AutoloadWarmerInterface
{
    private bool $includeVendor;
    private bool $includeTests;
    private array $includeNamespaces;
    private array $excludeNamespaces;
    private array $includeVendorNamespaces;
    private array $excludeVendorNamespaces;
    private ?string $vendorDir = null;
    private ?string $pharPrefix = null;

    private const KNOWN_BAD_NAMESPACES = [
        'Psy\\Readline\\Hoa\\',
        'Composer\\', // Autoloading Composer classes breaks Composer autoloading :grimacing:
    ];

    /**
     * PsySH's php-scoper prefix pattern for PHAR builds.
     *
     * Vendor dependencies in the PHAR are prefixed with "_Psy<hash>\" to avoid
     * conflicts with user dependencies. The hash is randomly generated per build.
     */
    private const PHAR_SCOPED_PREFIX_PATTERN = '/^_Psy[a-f0-9]+\\\\/';

    /**
     * @param array       $config    Configuration options
     * @param string|null $vendorDir Optional vendor directory path (auto-detected if not provided)
     */
    public function __construct(array $config = [], ?string $vendorDir = null)
    {
        $hasVendorFilters = isset($config['includeVendorNamespaces']) || isset($config['excludeVendorNamespaces']);

        // Validate conflicting config
        if ($hasVendorFilters && isset($config['includeVendor']) && $config['includeVendor'] === false) {
            throw new \InvalidArgumentException('Cannot use includeVendorNamespaces or excludeVendorNamespaces when includeVendor is false');
        }

        // Vendor namespace filters imply includeVendor: true
        $this->includeVendor = $config['includeVendor'] ?? $hasVendorFilters;
        $this->includeTests = $config['includeTests'] ?? false;
        $this->includeNamespaces = $this->normalizeNamespaces($config['includeNamespaces'] ?? []);
        $this->excludeNamespaces = $this->normalizeNamespaces($config['excludeNamespaces'] ?? []);
        $this->includeVendorNamespaces = $this->normalizeNamespaces($config['includeVendorNamespaces'] ?? []);
        $this->excludeVendorNamespaces = $this->normalizeNamespaces($config['excludeVendorNamespaces'] ?? []);

        // Cache PHAR prefix to avoid repeated Phar::running() calls
        if (Shell::isPhar()) {
            $runningPhar = \Phar::running(false);
            $this->pharPrefix = 'phar://'.$runningPhar.'/';
        }

        $vendorDir = $vendorDir ?? $this->findVendorDir();
        if ($vendorDir !== null) {
            $resolvedVendorDir = \realpath($vendorDir);
            if ($resolvedVendorDir !== false) {
                $this->vendorDir = \str_replace('\\', '/', $resolvedVendorDir);
            }
        }
    }

    /**
     * {@inheritdoc}
     */
    public function warm(): int
    {
        // Get count of already-loaded classes before we start
        $beforeCount = \count(\get_declared_classes()) +
                       \count(\get_declared_interfaces()) +
                       \count(\get_declared_traits());

        $classes = $this->getClassNames();
        foreach ($classes as $class) {
            try {
                // Skip if already loaded (check without autoloading first)
                if (
                    \class_exists($class, false) ||
                    \interface_exists($class, false) ||
                    \trait_exists($class, false)
                ) {
                    continue;
                }

                // Try to load the class/interface/trait
                // The autoload parameter (true) will trigger autoloading
                \class_exists($class, true) ||
                \interface_exists($class, true) ||
                \trait_exists($class, true);
            } catch (\Throwable $e) {
                // Ignore classes that fail to load
                // This is expected for classes with missing dependencies, etc.
            }
        }

        // Return the number of newly loaded classes
        $afterCount = \count(\get_declared_classes()) +
                      \count(\get_declared_interfaces()) +
                      \count(\get_declared_traits());

        return $afterCount - $beforeCount;
    }

    /**
     * Discover classes from available sources.
     *
     * Uses two complementary strategies:
     * 1. ClassLoader's classmap (from optimized autoload or registered classes)
     * 2. ClassMapGenerator to scan PSR-4 directories (if available, safe, no side effects)
     *
     * Both strategies are attempted and results are combined, since ClassLoader
     * may have an optimized classmap while ClassMapGenerator can discover new
     * classes added during development.
     *
     * @internal This method is exported for testability and is not part of the public API
     *
     * @return string[] Fully-qualified class names
     */
    public function getClassNames(): array
    {
        $autoloadMap = $this->getAutoloadClassMap();
        $generatedMap = $this->generateClassMap();

        return $this->classesFromClassMap(\array_merge($autoloadMap, $generatedMap));
    }

    /**
     * Get class map from the registered Composer ClassLoader, if available.
     *
     * This map is populated by running `composer dump-autoload`
     *
     * @return array Map of class name => file path
     */
    private function getAutoloadClassMap(): array
    {
        // If we found a project vendor dir, try to register their autoloader (if it hasn't been already)
        // Skip if vendor dir is inside a PHAR (don't re-require the PHAR's autoloader)
        if ($this->vendorDir !== null && !\str_starts_with($this->vendorDir, 'phar://')) {
            $projectAutoload = $this->vendorDir.'/autoload.php';
            if (\file_exists($projectAutoload)) {
                try {
                    require_once $projectAutoload;
                } catch (\Throwable $e) {
                    // Ignore autoloader errors
                }
            }
        }

        foreach (\spl_autoload_functions() as $autoloader) {
            if (!\is_array($autoloader)) {
                continue;
            }

            $loader = $autoloader[0] ?? null;
            if (!\is_object($loader) || \get_class($loader) !== 'Composer\Autoload\ClassLoader') {
                continue;
            }

            // Check if this loader contains scoped classes (indicates it's from the PHAR)
            if (\method_exists($loader, 'getClassMap')) {
                $classMap = $loader->getClassMap();
                foreach (\array_keys($classMap) as $class) {
                    if (\preg_match(self::PHAR_SCOPED_PREFIX_PATTERN, $class)) {
                        continue 2; // Skip to next autoloader
                    }
                }
            }

            // If we have an explicit vendor dir, check if this loader serves that directory
            if ($this->vendorDir !== null) {
                $hasTargetPaths = false;

                if (\method_exists($loader, 'getPrefixesPsr4')) {
                    $prefixes = $loader->getPrefixesPsr4();
                    foreach ($prefixes as $namespace => $paths) {
                        foreach ($paths as $path) {
                            if (\strpos($path, $this->vendorDir.'/') === 0) {
                                $hasTargetPaths = true;
                                break 2;
                            }
                        }
                    }
                }

                if (!$hasTargetPaths) {
                    continue;
                }
            }

            if (\method_exists($loader, 'getClassMap')) {
                return $loader->getClassMap();
            }

            return [];
        }

        return [];
    }

    /**
     * Scan autoload directories using a Composer ClassMapGenerator, if available.
     *
     * @return array Map of class name => file path
     */
    private function generateClassMap(): array
    {
        if ($this->vendorDir === null || !\class_exists('Composer\\ClassMapGenerator\\ClassMapGenerator', true)) {
            return [];
        }

        // Get PSR-4 mappings from Composer
        $psr4File = $this->vendorDir.'/composer/autoload_psr4.php';
        if (!\file_exists($psr4File)) {
            return [];
        }

        try {
            $psr4Map = require $psr4File;
            if (!\is_array($psr4Map)) {
                return [];
            }

            $generator = new \Composer\ClassMapGenerator\ClassMapGenerator();

            foreach ($psr4Map as $prefix => $paths) {
                foreach ($paths as $path) {
                    if (!\is_dir($path) || $this->shouldSkipPath($path)) {
                        continue;
                    }

                    try {
                        $generator->scanPaths($path);
                    } catch (\Throwable $e) {
                        // Ignore errors (permissions, malformed files, etc.)
                    }
                }
            }

            return $generator->getClassMap()->getMap();
        } catch (\Throwable $e) {
            return [];
        }
    }

    /**
     * Find the vendor directory by checking registered autoloaders, falling
     * back to filesystem search.
     *
     * @return string|null
     */
    private function findVendorDir(): ?string
    {
        // When running the PsySH PHAR, skip autoloader detection. It will just return the internal
        // vendor directory.
        if ($this->pharPrefix !== null) {
            // Try to find from autoloader
            foreach (\spl_autoload_functions() as $autoloader) {
                if (!\is_array($autoloader)) {
                    continue;
                }

                $loader = $autoloader[0] ?? null;
                if (!\is_object($loader)) {
                    continue;
                }

                try {
                    $reflection = new \ReflectionClass($loader);
                    $loaderPath = $reflection->getFileName();
                    $normalizedPath = \str_replace('\\', '/', $loaderPath);

                    // Skip any other PHAR-based autoloaders
                    if (\strpos($normalizedPath, 'phar://') === 0) {
                        continue;
                    }

                    // ClassLoader is typically at vendor/composer/ClassLoader.php
                    if (\strpos($normalizedPath, '/vendor/composer/') !== false) {
                        return \dirname($loaderPath, 2);
                    }
                } catch (\Throwable $e) {
                    // Ignore and try next autoloader
                }
            }
        }

        // Walk up the directory tree from cwd looking for vendor directory
        $dir = \getcwd();
        $root = \dirname($dir);

        while ($dir !== $root) {
            $vendorDir = $dir.'/vendor';
            if (\is_dir($vendorDir) && \file_exists($vendorDir.'/composer/autoload_psr4.php')) {
                return $vendorDir;
            }

            $root = $dir;
            $dir = \dirname($dir);
        }

        return null;
    }

    /**
     * Normalize namespace prefixes.
     *
     * Removes leading backslash and ensures trailing backslash.
     *
     * @param string[] $namespaces
     *
     * @return string[]
     */
    private function normalizeNamespaces(array $namespaces): array
    {
        return \array_map(function ($namespace) {
            return \trim($namespace, '\\').'\\';
        }, $namespaces);
    }

    /**
     * Check if a path should be skipped based on configuration.
     *
     * @param string $path File or directory path
     *
     * @return bool True if the path should be skipped
     */
    private function shouldSkipPath(string $path): bool
    {
        $normalizedPath = \str_replace('\\', '/', $path);

        // Skip paths from PsySH's own PHAR; these are PsySH's bundled dependencies, not user dependencies
        if ($this->isPathFromPhar($normalizedPath)) {
            return true;
        }

        // Check if path is under vendor directory
        if (!$this->includeVendor && $this->vendorDir !== null) {
            // Resolve relative paths like "vendor/composer/../../src/Cache.php" before comparing
            $resolvedPath = \realpath($path);
            if ($resolvedPath === false) {
                // File doesn't exist, permissions, etc. /shrug
                return true;
            }

            $resolvedPath = \str_replace('\\', '/', $resolvedPath);
            if (\strpos($resolvedPath, $this->vendorDir.'/') === 0) {
                return true;
            }
        }

        // Check test paths
        if (!$this->includeTests) {
            $patterns = ['/test/', '/tests/', '/spec/', '/specs/'];
            foreach ($patterns as $pattern) {
                if (\stripos($normalizedPath, $pattern) !== false) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Get classes from class map, filtered based on configured namespace rules and excluded paths.
     *
     * @param array $classMap Map of class name => file path
     *
     * @return string[]
     */
    private function classesFromClassMap(array $classMap): array
    {
        // First filter the map by path
        $classMap = \array_filter($classMap, function ($path) {
            return !$this->shouldSkipPath($path);
        });

        $classes = \array_keys($classMap);

        // Then filter by namespace
        return \array_values(
            \array_filter($classes, function ($class) use ($classMap) {
                // Exclude PsySH's scoped PHAR dependencies (e.g., _Psy3684f4474398\Symfony\...)
                if (\preg_match(self::PHAR_SCOPED_PREFIX_PATTERN, $class)) {
                    return false;
                }

                // Hardcode excluding known-bad classes
                foreach (self::KNOWN_BAD_NAMESPACES as $namespace) {
                    if (\stripos($class, $namespace) === 0) {
                        return false;
                    }
                }

                $isVendorClass = $this->isVendorClass($class, $classMap);

                // Apply vendor-specific exclude filters
                if ($isVendorClass && !empty($this->excludeVendorNamespaces)) {
                    foreach ($this->excludeVendorNamespaces as $namespace) {
                        if (\stripos($class, $namespace) === 0) {
                            return false;
                        }
                    }
                }

                // Apply general exclude filters
                foreach ($this->excludeNamespaces as $namespace) {
                    if (\stripos($class, $namespace) === 0) {
                        return false;
                    }
                }

                // Apply vendor-specific include filters
                if ($isVendorClass && !empty($this->includeVendorNamespaces)) {
                    foreach ($this->includeVendorNamespaces as $namespace) {
                        if (\stripos($class, $namespace) === 0) {
                            return true;
                        }
                    }

                    return false; // Vendor class doesn't match vendor filters
                }

                // Apply general include filters
                if (!empty($this->includeNamespaces)) {
                    foreach ($this->includeNamespaces as $namespace) {
                        if (\stripos($class, $namespace) === 0) {
                            return true;
                        }
                    }

                    return false;
                }

                // No include filters provided, and didn't match exclude filters
                return true;
            }),
        );
    }

    /**
     * Check if a class is from the vendor directory.
     *
     * @param string $class    Class name
     * @param array  $classMap Map of class name => file path
     *
     * @return bool
     */
    private function isVendorClass(string $class, array $classMap): bool
    {
        if ($this->vendorDir === null || !isset($classMap[$class])) {
            return false;
        }

        $path = $classMap[$class];

        // Resolve relative paths like "vendor/composer/../../src/Cache.php" before comparing
        // This ensures consistency with shouldSkipPath() logic
        $resolvedPath = \realpath($path);
        if ($resolvedPath === false) {
            // If realpath fails, fall back to raw path comparison
            // This matches the behavior in shouldSkipPath()
            $normalizedPath = \str_replace('\\', '/', $path);

            return \strpos($normalizedPath, $this->vendorDir.'/') === 0;
        }

        $normalizedPath = \str_replace('\\', '/', $resolvedPath);

        return \strpos($normalizedPath, $this->vendorDir.'/') === 0;
    }

    /**
     * Check if a path is from PsySH's own PHAR.
     *
     * @param string $path File path to check
     *
     * @return bool True if the path is from PsySH's PHAR
     */
    private function isPathFromPhar(string $path): bool
    {
        if ($this->pharPrefix === null || \strpos($path, 'phar://') !== 0) {
            return false;
        }

        return \strpos($path, $this->pharPrefix) === 0;
    }
}
