<?php

declare(strict_types=1);

namespace StaticPHP\Registry;

use StaticPHP\Attribute\Package\AfterStage;
use StaticPHP\Attribute\Package\BeforeStage;
use StaticPHP\Attribute\Package\BuildFor;
use StaticPHP\Attribute\Package\ConditionalOn;
use StaticPHP\Attribute\Package\CustomPhpConfigureArg;
use StaticPHP\Attribute\Package\Extension;
use StaticPHP\Attribute\Package\Info;
use StaticPHP\Attribute\Package\InitPackage;
use StaticPHP\Attribute\Package\Library;
use StaticPHP\Attribute\Package\PatchBeforeBuild;
use StaticPHP\Attribute\Package\ResolveBuild;
use StaticPHP\Attribute\Package\Stage;
use StaticPHP\Attribute\Package\Target;
use StaticPHP\Attribute\Package\Validate;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\RegistryException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Package\LibraryPackage;
use StaticPHP\Package\Package;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Package\PhpExtensionPackage;
use StaticPHP\Package\TargetPackage;
use StaticPHP\Util\FileSystem;

class PackageLoader
{
    /** @var array<string, Package> */
    private static ?array $packages = null;

    private static array $before_stages = [];

    private static array $after_stages = [];

    /** @var array<string, true> Track loaded classes to prevent duplicates */
    private static array $loaded_classes = [];

    /**
     * Annotation metadata keyed by package name, capturing the defining class and its method-level attributes.
     *
     * @var array<string, array{class: string, methods: array<string, list<array{attr: string, args: array<string, mixed>}>>}>
     */
    private static array $annotation_map = [];

    /**
     * Source metadata for #[BeforeStage] hooks, keyed by target package name → stage name.
     *
     * @var array<string, array<string, list<array{class: string, method: string, only_when: ?string, conditionals: list<class-string>}>>>
     */
    private static array $before_stage_meta = [];

    /**
     * Source metadata for #[AfterStage] hooks, keyed by target package name → stage name.
     *
     * @var array<string, array<string, list<array{class: string, method: string, only_when: ?string, conditionals: list<class-string>}>>>
     */
    private static array $after_stage_meta = [];

    /**
     * Reverse index of #[BeforeStage] hooks, keyed by registering class → target package → stage.
     * Enables O(1) "outbound hook" lookup: what stages does a given class hook into on other packages?
     *
     * @var array<string, array<string, array<string, list<array{method: string, only_when: ?string}>>>>
     */
    private static array $class_before_stage_meta = [];

    /**
     * Reverse index of #[AfterStage] hooks, keyed by registering class → target package → stage.
     *
     * @var array<string, array<string, array<string, list<array{method: string, only_when: ?string}>>>>
     */
    private static array $class_after_stage_meta = [];

    public static function initPackageInstances(): void
    {
        if (self::$packages !== null) {
            return;
        }
        // init packages instance from config
        foreach (PackageConfig::getAll() as $name => $item) {
            $pkg = match ($item['type']) {
                'target', 'virtual-target' => new TargetPackage($name, $item['type']),
                'library' => new LibraryPackage($name, $item['type']),
                'php-extension' => new PhpExtensionPackage($name, $item['type']),
                default => null,
            };
            if ($pkg !== null) {
                self::$packages[$name] = $pkg;
            } else {
                throw new RegistryException("Package [{$name}] has unknown type [{$item['type']}]");
            }
        }
    }

    /**
     * Load package definitions from PSR-4 directory.
     *
     * @param string $dir            Directory path
     * @param string $base_namespace Base namespace for dir's PSR-4 mapping
     * @param bool   $auto_require   Whether to auto-require PHP files (for external plugins not in autoload)
     */
    public static function loadFromPsr4Dir(string $dir, string $base_namespace, bool $auto_require = false): void
    {
        self::initPackageInstances();
        $classes = FileSystem::getClassesPsr4($dir, $base_namespace, auto_require: $auto_require);
        foreach ($classes as $class) {
            self::loadFromClass($class);
        }
    }

    public static function hasPackage(string $name): bool
    {
        return isset(self::$packages[$name]);
    }

    /**
     * Get a Package instance by its name.
     *
     * @param  string  $name The name of the package
     * @return Package Returns the Package instance if found, otherwise null
     */
    public static function getPackage(string $name): Package
    {
        if (!isset(self::$packages[$name])) {
            throw new WrongUsageException("Package [{$name}] not found.");
        }
        return self::$packages[$name];
    }

    public static function getTargetPackage(string $name): TargetPackage
    {
        $pkg = self::getPackage($name);
        if ($pkg instanceof TargetPackage) {
            return $pkg;
        }
        throw new WrongUsageException("Package [{$name}] is not a TargetPackage.");
    }

    public static function getLibraryPackage(string $name): LibraryPackage
    {
        $pkg = self::getPackage($name);
        if ($pkg instanceof LibraryPackage) {
            return $pkg;
        }
        throw new WrongUsageException("Package [{$name}] is not a LibraryPackage.");
    }

    /**
     * Get all loaded Package instances.
     */
    public static function getPackages(array|string|null $type_filter = null): iterable
    {
        foreach (self::$packages as $name => $package) {
            if ($type_filter === null) {
                yield $name => $package;
            } elseif ($package->getType() === $type_filter) {
                yield $name => $package;
            } elseif (is_array($type_filter) && in_array($package->getType(), $type_filter, true)) {
                yield $name => $package;
            }
        }
    }

    /**
     * Init package instance from defined classes and attributes.
     *
     * @internal
     */
    public static function loadFromClass(mixed $class): void
    {
        $refClass = new \ReflectionClass($class);
        $class_name = $refClass->getName();

        // Skip if already loaded to prevent duplicate registrations
        if (isset(self::$loaded_classes[$class_name])) {
            return;
        }
        self::$loaded_classes[$class_name] = true;

        $attributes = $refClass->getAttributes();
        foreach ($attributes as $attribute) {
            $pkg = null;

            $attribute_instance = $attribute->newInstance();
            if ($attribute_instance instanceof Target === false &&
                $attribute_instance instanceof Library === false &&
                $attribute_instance instanceof Extension === false) {
                // not a package attribute
                continue;
            }
            $package_type = PackageConfig::get($attribute_instance->name, 'type');
            if ($package_type === null) {
                throw new RegistryException("Package [{$attribute_instance->name}] not defined in config, but referenced from class {$class}, please check your config files.");
            }

            // if class has parent class and matches the attribute instance, use custom class
            if ($refClass->getParentClass() !== false) {
                if (is_a($class_name, Package::class, true)) {
                    self::$packages[$attribute_instance->name] = new $class_name($attribute_instance->name, $package_type);
                }
            }

            $pkg = self::$packages[$attribute_instance->name] ?? null;

            // Use the package instance if it's a Package subclass, otherwise create a new instance
            $instance_class = is_a($class_name, Package::class, true) ? $pkg : $refClass->newInstance();

            // validate package type matches
            $pkg_type_attr = match ($attribute->getName()) {
                Target::class => ['target', 'virtual-target'],
                Library::class => ['library'],
                Extension::class => ['php-extension'],
                default => null,
            };
            if (!in_array($package_type, $pkg_type_attr, true)) {
                throw new RegistryException("Package [{$attribute_instance->name}] type mismatch: config type is [{$package_type}], but attribute type is [" . implode('|', $pkg_type_attr) . '].');
            }
            if ($pkg instanceof Package && !PackageConfig::isPackageExists($pkg->getName())) {
                throw new RegistryException("Package [{$pkg->getName()}] config not found for class {$class}");
            }

            // init method attributes
            $methods = $refClass->getMethods(\ReflectionMethod::IS_PUBLIC);
            foreach ($methods as $method) {
                $method_attributes = $method->getAttributes();
                foreach ($method_attributes as $method_attribute) {
                    $method_instance = $method_attribute->newInstance();
                    match ($method_attribute->getName()) {
                        // #[BuildFor(PHP_OS_FAMILY)]
                        BuildFor::class => self::addBuildFunction($pkg, $method_instance, [$instance_class, $method->getName()]),
                        // #[BeforeBuild]
                        PatchBeforeBuild::class => self::addPatchBeforeBuildFunction($pkg, [$instance_class, $method->getName()]),
                        // #[CustomPhpConfigureArg(PHP_OS_FAMILY)]
                        CustomPhpConfigureArg::class => self::bindCustomPhpConfigureArg($pkg, $method_attribute->newInstance(), [$instance_class, $method->getName()]),
                        // #[Stage('stage_name')]
                        Stage::class => self::addStage($method, $pkg, $instance_class, $method_instance),
                        // #[InitPackage] (run now with package context)
                        InitPackage::class => ApplicationContext::invoke([$instance_class, $method->getName()], ['package' => $pkg]),
                        // #[InitBuild]
                        ResolveBuild::class => $pkg instanceof TargetPackage ? $pkg->setResolveBuildCallback([$instance_class, $method->getName()]) : null,
                        // #[Info]
                        Info::class => $pkg->setInfoCallback([$instance_class, $method->getName()]),
                        // #[Validate]
                        Validate::class => $pkg->setValidateCallback([$instance_class, $method->getName()]),
                        default => null,
                    };

                    // Capture annotation metadata for inspection (dev:info, future event-trace commands)
                    $meta_attr = self::annotationShortName($method_attribute->getName());
                    if ($meta_attr !== null) {
                        self::$annotation_map[$pkg->getName()]['methods'][$method->getName()][] = [
                            'attr' => $meta_attr,
                            'args' => self::annotationArgs($method_instance),
                        ];
                    }
                }
            }
            // Record which class defines this package (set once; IS_REPEATABLE may loop more than once)
            self::$annotation_map[$pkg->getName()]['class'] ??= $class_name;
            // register package
            self::$packages[$pkg->getName()] = $pkg;
        }

        // For classes without package attributes, create a simple instance for non-package stage callbacks
        if (!isset($instance_class)) {
            $instance_class = $refClass->newInstance();
        }

        // parse non-package available attributes
        foreach ($refClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
            $method_attributes = $method->getAttributes();
            foreach ($method_attributes as $method_attribute) {
                $method_instance = $method_attribute->newInstance();
                match ($method_attribute->getName()) {
                    // #[BeforeStage('package_name', 'stage')] and #[AfterStage('package_name', 'stage')]
                    BeforeStage::class => self::addBeforeStage($method, $pkg ?? null, $instance_class, $method_instance),
                    AfterStage::class => self::addAfterStage($method, $pkg ?? null, $instance_class, $method_instance),

                    default => null,
                };
            }
        }
    }

    /**
     * Get all registered before-stage callbacks (raw).
     *
     * @return array<string, array<string, list<array{0: callable, 1: ?string}>>>
     */
    public static function getAllBeforeStages(): array
    {
        return self::$before_stages;
    }

    /**
     * Get all registered after-stage callbacks (raw).
     *
     * @return array<string, array<string, list<array{0: callable, 1: ?string}>>>
     */
    public static function getAllAfterStages(): array
    {
        return self::$after_stages;
    }

    /**
     * Get annotation metadata for a specific package.
     *
     * Returns null if no annotation class was loaded for this package (config-only package).
     * The returned structure includes the defining class name, per-method attribute list,
     * inbound BeforeStage/AfterStage hooks targeting this package, and outbound hooks that
     * this package's class registers on other packages.
     *
     * @return null|array{
     *   class: string,
     *   methods: array<string, list<array{attr: string, args: array<string, mixed>}>>,
     *   before_stages: array<string, list<array{class: string, method: string, only_when: ?string}>>,
     *   after_stages: array<string, list<array{class: string, method: string, only_when: ?string}>>,
     *   outbound_before_stages: array<string, array<string, list<array{method: string, only_when: ?string}>>>,
     *   outbound_after_stages: array<string, array<string, list<array{method: string, only_when: ?string}>>>
     * }
     */
    public static function getPackageAnnotationInfo(string $name): ?array
    {
        $class_info = self::$annotation_map[$name] ?? null;
        if ($class_info === null) {
            return null;
        }
        $class = $class_info['class'];
        return [
            'class' => $class,
            'methods' => $class_info['methods'],
            'before_stages' => self::$before_stage_meta[$name] ?? [],
            'after_stages' => self::$after_stage_meta[$name] ?? [],
            'outbound_before_stages' => self::$class_before_stage_meta[$class] ?? [],
            'outbound_after_stages' => self::$class_after_stage_meta[$class] ?? [],
        ];
    }

    /**
     * Get all annotation metadata keyed by package name.
     * Useful for future event-trace commands or cross-package inspection.
     *
     * @return array<string, array{class: string, methods: array, before_stages: array, after_stages: array, outbound_before_stages: array, outbound_after_stages: array}>
     */
    public static function getAllAnnotations(): array
    {
        $result = [];
        foreach (self::$annotation_map as $name => $info) {
            $class = $info['class'];
            $result[$name] = [
                'class' => $class,
                'methods' => $info['methods'],
                'before_stages' => self::$before_stage_meta[$name] ?? [],
                'after_stages' => self::$after_stage_meta[$name] ?? [],
                'outbound_before_stages' => self::$class_before_stage_meta[$class] ?? [],
                'outbound_after_stages' => self::$class_after_stage_meta[$class] ?? [],
            ];
        }
        return $result;
    }

    public static function getBeforeStageCallbacks(string $package_name, string $stage): iterable
    {
        // match condition
        $installer = ApplicationContext::get(PackageInstaller::class);
        $stages = self::$before_stages[$package_name][$stage] ?? [];
        foreach ($stages as [$callback, $only_when_package_resolved, $conditionals]) {
            if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) {
                continue;
            }
            foreach ($conditionals as $class) {
                if (!ApplicationContext::has($class)) {
                    continue 2;
                }
            }
            yield $callback;
        }
    }

    public static function getAfterStageCallbacks(string $package_name, string $stage): array
    {
        // match condition
        $installer = ApplicationContext::get(PackageInstaller::class);
        $stages = self::$after_stages[$package_name][$stage] ?? [];
        $result = [];
        foreach ($stages as [$callback, $only_when_package_resolved, $conditionals]) {
            if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) {
                continue;
            }
            foreach ($conditionals as $class) {
                if (!ApplicationContext::has($class)) {
                    continue 2;
                }
            }
            $result[] = $callback;
        }
        return $result;
    }

    /**
     * Register default stages for all PhpExtensionPackage instances.
     * Should be called after all registries have been loaded.
     */
    public static function registerAllDefaultStages(): void
    {
        foreach (self::$packages as $pkg) {
            if ($pkg instanceof PhpExtensionPackage) {
                $pkg->registerDefaultStages();
            } elseif ($pkg instanceof LibraryPackage) {
                $pkg->registerDefaultStages();
            }
        }
    }

    /**
     * Check loaded stage events for consistency.
     */
    public static function checkLoadedStageEvents(): void
    {
        foreach (['BeforeStage' => self::$before_stages, 'AfterStage' => self::$after_stages] as $event_name => $ev_all) {
            foreach ($ev_all as $package_name => $stages) {
                // check package exists
                if (!self::hasPackage($package_name)) {
                    throw new RegistryException(
                        "{$event_name} event registered for unknown package [{$package_name}]."
                    );
                }
                $pkg = self::getPackage($package_name);
                foreach ($stages as $stage_name => $before_events) {
                    foreach ($before_events as [$event_callable, $only_when_package_resolved, $conditionals]) {
                        // check only_when_package_resolved package exists
                        if ($only_when_package_resolved !== null && !self::hasPackage($only_when_package_resolved)) {
                            throw new RegistryException("{$event_name} event in package [{$package_name}] for stage [{$stage_name}] has unknown only_when_package_resolved package [{$only_when_package_resolved}].");
                        }
                        // check callable is valid
                        if (!is_callable($event_callable)) {
                            throw new RegistryException(
                                "{$event_name} event in package [{$package_name}] for stage [{$stage_name}] has invalid callable.",
                            );
                        }
                    }
                    // check stage exists
                    // Skip validation if the package has no build function for current OS
                    // (e.g., libedit has BeforeStage for 'build' but only BuildFor('Darwin'/'Linux'))
                    if (!$pkg->hasStage($stage_name) && $pkg->hasBuildFunctionForCurrentOS()) {
                        throw new RegistryException("Package stage [{$stage_name}] is not registered in package [{$package_name}].");
                    }
                }
            }
        }
    }

    /**
     * Bind a custom PHP configure argument callback to a php-extension package.
     */
    private static function bindCustomPhpConfigureArg(Package $pkg, object $attr, callable $fn): void
    {
        if (!$pkg instanceof PhpExtensionPackage) {
            throw new RegistryException("Class [{$pkg->getName()}] must implement PhpExtensionPackage for CustomPhpConfigureArg attribute.");
        }
        $pkg->addCustomPhpConfigureArgCallback($attr->os, $fn);
    }

    private static function addBuildFunction(Package $pkg, object $attr, callable $fn): void
    {
        $pkg->addBuildFunction($attr->os, $fn);
    }

    private static function addPatchBeforeBuildFunction(Package $pkg, callable $fn): void
    {
        $pkg->addPatchBeforeBuildCallback($fn);
    }

    private static function addStage(\ReflectionMethod $method, Package $pkg, object $instance_class, object $method_instance): void
    {
        $name = $method_instance->function;
        if ($name === null) {
            $name = $method->getName();
        }
        $pkg->addStage($name, [$instance_class, $method->getName()]);
    }

    private static function addBeforeStage(\ReflectionMethod $method, ?Package $pkg, mixed $instance_class, object $method_instance): void
    {
        /** @var BeforeStage $method_instance */
        $stage = $method_instance->stage;
        $stage = match (true) {
            is_string($stage) => $stage,
            count($stage) === 2 => $stage[1],
            default => throw new RegistryException('Invalid stage definition in BeforeStage attribute.'),
        };
        if ($method_instance->package_name === '' && $pkg === null) {
            throw new RegistryException('Package name must not be empty when no package context is available for BeforeStage attribute.');
        }
        $package_name = $method_instance->package_name === '' ? $pkg->getName() : $method_instance->package_name;

        $conditionals = array_map(
            fn (\ReflectionAttribute $a) => $a->newInstance()->class,
            [...$method->getDeclaringClass()->getAttributes(ConditionalOn::class), ...$method->getAttributes(ConditionalOn::class)],
        );
        self::$before_stages[$package_name][$stage][] = [[$instance_class, $method->getName()], $method_instance->only_when_package_resolved, $conditionals];
        $registering_class = get_class($instance_class);
        self::$before_stage_meta[$package_name][$stage][] = [
            'class' => $registering_class,
            'method' => $method->getName(),
            'only_when' => $method_instance->only_when_package_resolved,
            'conditionals' => $conditionals,
        ];
        self::$class_before_stage_meta[$registering_class][$package_name][$stage][] = [
            'method' => $method->getName(),
            'only_when' => $method_instance->only_when_package_resolved,
        ];
    }

    private static function addAfterStage(\ReflectionMethod $method, ?Package $pkg, mixed $instance_class, object $method_instance): void
    {
        $stage = $method_instance->stage;
        $stage = match (true) {
            is_string($stage) => $stage,
            is_array($stage) && count($stage) === 2 => $stage[1],
            default => throw new RegistryException('Invalid stage definition in AfterStage attribute.'),
        };
        if ($method_instance->package_name === '' && $pkg === null) {
            throw new RegistryException('Package name must not be empty when no package context is available for AfterStage attribute.');
        }
        $package_name = $method_instance->package_name === '' ? $pkg->getName() : $method_instance->package_name;

        $conditionals = array_map(
            fn (\ReflectionAttribute $a) => $a->newInstance()->class,
            [...$method->getDeclaringClass()->getAttributes(ConditionalOn::class), ...$method->getAttributes(ConditionalOn::class)],
        );
        self::$after_stages[$package_name][$stage][] = [[$instance_class, $method->getName()], $method_instance->only_when_package_resolved, $conditionals];
        $registering_class = get_class($instance_class);
        self::$after_stage_meta[$package_name][$stage][] = [
            'class' => $registering_class,
            'method' => $method->getName(),
            'only_when' => $method_instance->only_when_package_resolved,
            'conditionals' => $conditionals,
        ];
        self::$class_after_stage_meta[$registering_class][$package_name][$stage][] = [
            'method' => $method->getName(),
            'only_when' => $method_instance->only_when_package_resolved,
        ];
    }

    /**
     * Map a fully-qualified attribute class name to a short display name for metadata storage.
     * Returns null for attributes that are not tracked in the annotation map.
     */
    private static function annotationShortName(string $attr): ?string
    {
        return match ($attr) {
            Stage::class => 'Stage',
            BuildFor::class => 'BuildFor',
            PatchBeforeBuild::class => 'PatchBeforeBuild',
            CustomPhpConfigureArg::class => 'CustomPhpConfigureArg',
            InitPackage::class => 'InitPackage',
            ResolveBuild::class => 'ResolveBuild',
            Info::class => 'Info',
            Validate::class => 'Validate',
            ConditionalOn::class => 'ConditionalOn',
            default => null,
        };
    }

    /**
     * Extract the meaningful constructor arguments from an attribute instance as a key-value array.
     *
     * @return array<string, mixed>
     */
    private static function annotationArgs(object $inst): array
    {
        return match (true) {
            $inst instanceof Stage => array_filter(['function' => $inst->function], fn ($v) => $v !== null),
            $inst instanceof BuildFor => ['os' => $inst->os],
            $inst instanceof CustomPhpConfigureArg => array_filter(['os' => $inst->os], fn ($v) => $v !== ''),
            default => [],
        };
    }
}
