<?php

namespace MintyPHP\App\Module;

use Composer\Autoload\ClassLoader;
use RuntimeException;

/**
 * Loads, validates and merges module manifests.
 *
 * Modules are discovered from a configurable directory and activated via an
 * allow-list (config/modules.php + APP_ENABLED_MODULES env variable).
 *
 * The registry is immutable after construction — all contributions are merged
 * at boot time and available through typed getters.
 */
final class ModuleRegistry
{
    /** @var array<string, ModuleManifest> keyed by module id */
    private array $modules = [];

    /** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
    private array $mergedRoutes = [];

    /** @var list<string> merged public paths */
    private array $mergedPublicPaths = [];

    /** @var list<class-string> merged container registrar FQCNs */
    private array $mergedContainerRegistrars = [];

    /** @var array<string, list<mixed>> merged UI slots keyed by slot name */
    private array $mergedUiSlots = [];

    /** @var list<class-string> merged search resource provider FQCNs */
    private array $mergedSearchResources = [];

    /** @var array<string, list<string>> merged asset groups */
    private array $mergedAssetGroups = [];

    /** @var list<class-string> merged layout context provider FQCNs */
    private array $mergedLayoutContextProviders = [];

    /** @var list<class-string> merged session provider FQCNs */
    private array $mergedSessionProviders = [];

    /**
     * @var list<array{
     *     key: string,
     *     description: string,
     *     active: int,
     *     is_system: int
     * }> merged permissions
     */
    private array $mergedPermissions = [];

    /** @var list<class-string> merged authorization policy FQCNs */
    private array $mergedAuthorizationPolicies = [];

    /**
     * @var list<array{
     *     job_key: string,
     *     handler: string,
     *     label: string,
     *     description: string,
     *     default_enabled: int,
     *     default_timezone: string,
     *     default_schedule_type: string,
     *     default_schedule_interval: int,
     *     default_schedule_time: ?string,
     *     default_schedule_weekdays_csv: ?string,
     *     default_catchup_once: int,
     *     allowed_schedule_types: list<string>,
     *     module_id: string
     * }>
     */
    private array $mergedSchedulerJobs = [];

    /** @var array<string, string> route target => owning module id */
    private array $routeTargetOwners = [];

    /** @var array<string, string> route path => owning module id */
    private array $routePathOwners = [];

    /** @var array<string, string> permission key => owning module id */
    private array $permissionOwners = [];

    /** @var array<string, string> scheduler job key => owning module id */
    private array $schedulerJobOwners = [];

    /** @var array<string, list<array{class: class-string, method: string}>> merged event listeners keyed by event name */
    private array $mergedEventListeners = [];

    /**
     * Boot the registry from a modules directory and activation config.
     *
     * @param string $modulesDir Absolute path to the modules/ directory
     * @param list<string> $enabledModuleIds Module IDs to activate (from config + env)
     */
    public static function boot(string $modulesDir, array $enabledModuleIds): self
    {
        $registry = new self();

        if (!is_dir($modulesDir)) {
            return $registry;
        }

        // Discover and load manifests for enabled modules
        $manifests = [];
        foreach ($enabledModuleIds as $moduleId) {
            $moduleId = trim($moduleId);
            if ($moduleId === '') {
                continue;
            }

            $modulePath = rtrim($modulesDir, '/') . '/' . $moduleId;
            $manifestFile = $modulePath . '/module.php';

            if (!is_file($manifestFile)) {
                throw new RuntimeException(
                    "Module '{$moduleId}' is enabled but manifest not found at: {$manifestFile}"
                );
            }

            $raw = require $manifestFile;
            if (!is_array($raw)) {
                throw new RuntimeException(
                    "Module manifest at '{$manifestFile}' must return an array."
                );
            }

            ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);

            $manifest = ModuleManifest::fromArray($raw, $modulePath);

            if ($manifest->id !== $moduleId) {
                throw new RuntimeException(
                    "Module directory '{$moduleId}' does not match manifest id '{$manifest->id}'."
                );
            }

            $manifests[] = $manifest;
        }

        // Sort deterministically: load_order ASC, then id ASC
        usort($manifests, static function (ModuleManifest $a, ModuleManifest $b): int {
            return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
        });

        // Register module lib dirs with Composer's PSR-4 autoloader so that
        // module classes (MintyPHP\Module\<Name>\*) resolve without a custom loader.
        self::registerModuleAutoloading($manifests);

        // Preload core + module translations into I18n before any t() call.
        $coreI18nDir = rtrim($modulesDir, '/') . '/../i18n';
        if (is_dir($coreI18nDir)) {
            ModuleI18nLoader::preload($manifests, $coreI18nDir);
        }

        // Register and merge
        foreach ($manifests as $manifest) {
            $registry->registerModule($manifest);
        }

        $registry->validateDependencies();
        $registry->finalizeMergedContributions();

        return $registry;
    }

    /**
     * Resolve enabled module IDs from config array + ENV override.
     *
     * @param array{enabled_modules?: list<string>} $config From config/modules.php
     * @return list<string>
     */
    public static function resolveEnabledModules(array $config): array
    {
        $fromConfig = $config['enabled_modules'] ?? [];
        $fromEnvRaw = getenv('APP_ENABLED_MODULES');

        // Semantics:
        // - not set  => use config/modules.php
        // - set ''   => explicit "no modules"
        // - set list => exact list from env
        if ($fromEnvRaw !== false) {
            $fromEnv = trim((string) $fromEnvRaw);
            $fromConfig = $fromEnv === ''
                ? []
                : array_map('trim', explode(',', $fromEnv));
        }

        return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== '')));
    }

    /**
     * Register module lib/ directories with Composer's ClassLoader.
     *
     * @param list<ModuleManifest> $manifests
     */
    private static function registerModuleAutoloading(array $manifests): void
    {
        $composerLoader = null;
        foreach (spl_autoload_functions() as $autoloader) {
            if (is_array($autoloader) && $autoloader[0] instanceof ClassLoader) {
                $composerLoader = $autoloader[0];
                break;
            }
        }

        if ($composerLoader === null) {
            return;
        }

        foreach ($manifests as $manifest) {
            $libDir = rtrim($manifest->basePath, '/') . '/lib';
            if (is_dir($libDir)) {
                $composerLoader->addPsr4('MintyPHP\\', [$libDir]);
            }
        }
    }

    private function registerModule(ModuleManifest $manifest): void
    {
        if (isset($this->modules[$manifest->id])) {
            throw new RuntimeException(
                "Duplicate module id: '{$manifest->id}' is already registered."
            );
        }

        $this->modules[$manifest->id] = $manifest;

        // — Merge routes (fail-fast on collision) ——————————————
        foreach ($manifest->routes as $rawRoute) {
            $route = $this->normalizeRoute($rawRoute, $manifest->id);
            $path = $route['path'];
            $target = $route['target'];
            if (isset($this->routeTargetOwners[$target]) && $this->routeTargetOwners[$target] !== $manifest->id) {
                $owner = $this->routeTargetOwners[$target];
                throw new RuntimeException(
                    "Route target conflict: '{$target}' from module '{$manifest->id}' collides with module '{$owner}'."
                );
            }
            if (isset($this->routePathOwners[$path])) {
                $owner = $this->routePathOwners[$path];
                throw new RuntimeException(
                    "Route path conflict: '{$path}' from module '{$manifest->id}' collides with module '{$owner}'."
                );
            }
            $this->routeTargetOwners[$target] = $manifest->id;
            $this->routePathOwners[$path] = $manifest->id;
            $this->mergedRoutes[] = $route;
            if (!empty($route['public'])) {
                $this->mergedPublicPaths[] = $path;
                $this->mergedPublicPaths[] = $target;
            }
        }

        // — Merge public paths ———————————————————————————
        array_push($this->mergedPublicPaths, ...$manifest->publicPaths);

        // — Merge container registrars ————————————————————
        array_push($this->mergedContainerRegistrars, ...$manifest->containerRegistrars);

        // — Merge UI slots (fail-fast on duplicate keys within same slot) ——
        foreach ($manifest->uiSlots as $slotName => $contributions) {
            if (!isset($this->mergedUiSlots[$slotName])) {
                $this->mergedUiSlots[$slotName] = [];
            }
            $contributionList = is_array($contributions) ? $contributions : [$contributions];
            foreach ($contributionList as $contribution) {
                $normalizedContribution = $this->normalizeUiSlotContribution($slotName, $contribution, $manifest->id, $manifest->basePath);
                $key = (string) $normalizedContribution['key'];
                foreach ($this->mergedUiSlots[$slotName] as $existing) {
                    $existingKey = is_array($existing) ? ($existing['key'] ?? null) : null;
                    if ($existingKey === $key) {
                        throw new RuntimeException(
                            "UI slot conflict: slot '{$slotName}' key '{$key}' from module '{$manifest->id}' already exists."
                        );
                    }
                }
                $this->mergedUiSlots[$slotName][] = $normalizedContribution;
            }
        }

        // — Merge search resources (fail-fast on duplicate provider) ——
        foreach ($manifest->searchResources as $provider) {
            if (trim($provider) === '') {
                throw new RuntimeException(
                    "Search resource provider in module '{$manifest->id}' must be a non-empty class-string."
                );
            }
            if (in_array($provider, $this->mergedSearchResources, true)) {
                throw new RuntimeException(
                    "Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered."
                );
            }
            $this->mergedSearchResources[] = $provider;
        }

        // — Merge asset groups ————————————————————————————
        foreach ($manifest->assetGroups as $groupName => $files) {
            if (isset($this->mergedAssetGroups[$groupName])) {
                throw new RuntimeException(
                    "Asset group conflict: group '{$groupName}' from module '{$manifest->id}' already exists."
                );
            }
            $this->mergedAssetGroups[$groupName] = $files;
        }

        // — Merge permissions (fail-fast on duplicate) ———————
        foreach ($manifest->permissions as $permission) {
            $permissionKey = trim((string) $permission['key']);
            if ($permissionKey === '') {
                throw new RuntimeException(
                    "Permission in module '{$manifest->id}' must define non-empty 'key'."
                );
            }

            if (isset($this->permissionOwners[$permissionKey])) {
                $owner = $this->permissionOwners[$permissionKey];
                throw new RuntimeException(
                    "Permission conflict: '{$permissionKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
                );
            }

            $this->permissionOwners[$permissionKey] = $manifest->id;
            $this->mergedPermissions[] = [
                'key' => $permissionKey,
                'description' => trim((string) $permission['description']),
                'active' => (int) $permission['active'],
                'is_system' => (int) $permission['is_system'],
            ];
        }

        // — Merge layout context providers ————————————————
        foreach ($manifest->layoutContextProviders as $providerClass) {
            if (trim($providerClass) === '') {
                throw new RuntimeException(
                    "Layout context provider in module '{$manifest->id}' must be a non-empty class-string."
                );
            }
            $this->mergedLayoutContextProviders[] = $providerClass;
        }

        // — Merge session providers ——————————————————————
        foreach ($manifest->sessionProviders as $providerClass) {
            if (trim($providerClass) === '') {
                throw new RuntimeException(
                    "Session provider in module '{$manifest->id}' must be a non-empty class-string."
                );
            }
            $this->mergedSessionProviders[] = $providerClass;
        }

        // — Merge authorization policies (fail-fast on duplicate) ———
        foreach ($manifest->authorizationPolicies as $policyClass) {
            if (trim($policyClass) === '') {
                throw new RuntimeException(
                    "Authorization policy in module '{$manifest->id}' must be a non-empty class-string."
                );
            }
            if (in_array($policyClass, $this->mergedAuthorizationPolicies, true)) {
                throw new RuntimeException(
                    "Authorization policy conflict: '{$policyClass}' from module '{$manifest->id}' is already registered."
                );
            }
            $this->mergedAuthorizationPolicies[] = $policyClass;
        }

        // — Merge event listeners ——————————————————————————
        foreach ($manifest->eventListeners as $eventName => $handlers) {
            if (!isset($this->mergedEventListeners[$eventName])) {
                $this->mergedEventListeners[$eventName] = [];
            }
            array_push($this->mergedEventListeners[$eventName], ...$handlers);
        }

        // — Merge scheduler jobs —————————————————————————
        foreach ($manifest->schedulerJobs as $schedulerJob) {
            $jobKey = trim((string) $schedulerJob['job_key']);
            if ($jobKey === '') {
                throw new RuntimeException(
                    "Scheduler job in module '{$manifest->id}' must define non-empty 'job_key'."
                );
            }
            if (isset($this->schedulerJobOwners[$jobKey])) {
                $owner = $this->schedulerJobOwners[$jobKey];
                throw new RuntimeException(
                    "Scheduler job conflict: '{$jobKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
                );
            }

            $this->schedulerJobOwners[$jobKey] = $manifest->id;
            $this->mergedSchedulerJobs[] = [
                'job_key' => $jobKey,
                'handler' => trim((string) $schedulerJob['handler']),
                'label' => trim((string) $schedulerJob['label']),
                'description' => trim((string) $schedulerJob['description']),
                'default_enabled' => (int) $schedulerJob['default_enabled'],
                'default_timezone' => trim((string) $schedulerJob['default_timezone']),
                'default_schedule_type' => trim((string) $schedulerJob['default_schedule_type']),
                'default_schedule_interval' => (int) $schedulerJob['default_schedule_interval'],
                'default_schedule_time' => $schedulerJob['default_schedule_time'],
                'default_schedule_weekdays_csv' => $schedulerJob['default_schedule_weekdays_csv'],
                'default_catchup_once' => (int) $schedulerJob['default_catchup_once'],
                'allowed_schedule_types' => $schedulerJob['allowed_schedule_types'],
                'module_id' => $manifest->id,
            ];
        }
    }

    private function validateDependencies(): void
    {
        $enabledIds = array_keys($this->modules);

        // Check all required modules are enabled
        foreach ($this->modules as $manifest) {
            foreach ($manifest->requires as $requiredId) {
                if (!isset($this->modules[$requiredId])) {
                    throw new RuntimeException(
                        "Module '{$manifest->id}' requires module '{$requiredId}' which is not enabled."
                    );
                }
            }
        }

        // Check for circular dependencies (DFS cycle detection)
        $visited = [];
        $inStack = [];

        $visit = function (string $moduleId) use (&$visit, &$visited, &$inStack): void {
            if (isset($inStack[$moduleId])) {
                throw new RuntimeException(
                    "Circular module dependency detected involving module '{$moduleId}'."
                );
            }
            if (isset($visited[$moduleId])) {
                return;
            }

            $inStack[$moduleId] = true;

            if (isset($this->modules[$moduleId])) {
                foreach ($this->modules[$moduleId]->requires as $requiredId) {
                    $visit($requiredId);
                }
            }

            unset($inStack[$moduleId]);
            $visited[$moduleId] = true;
        };

        foreach ($enabledIds as $moduleId) {
            $visit($moduleId);
        }
    }

    private function finalizeMergedContributions(): void
    {
        $this->mergedPublicPaths = array_values(array_unique(array_filter(
            $this->mergedPublicPaths,
            static fn (mixed $path): bool => trim((string) $path) !== ''
        )));

        foreach ($this->mergedUiSlots as $slotName => $items) {
            usort($items, static function (array $a, array $b): int {
                $orderCmp = ((int) ($a['order'] ?? 100)) <=> ((int) ($b['order'] ?? 100));
                if ($orderCmp !== 0) {
                    return $orderCmp;
                }
                $moduleCmp = strcmp((string) ($a['__module_id'] ?? ''), (string) ($b['__module_id'] ?? ''));
                if ($moduleCmp !== 0) {
                    return $moduleCmp;
                }
                return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
            });

            foreach ($items as &$item) {
                unset($item['__module_id']);
            }
            unset($item);

            $this->mergedUiSlots[$slotName] = $items;
        }
    }

    /**
     * @return array{path: string, target: string, module_id: string, public?: bool}
     */
    private function normalizeRoute(mixed $rawRoute, string $moduleId): array
    {
        if (!is_array($rawRoute)) {
            throw new RuntimeException("Route entries in module '{$moduleId}' must be arrays.");
        }

        $path = trim((string) ($rawRoute['path'] ?? ''));
        $target = trim((string) ($rawRoute['target'] ?? ''));
        if ($path === '' || $target === '') {
            throw new RuntimeException("Routes in module '{$moduleId}' must define non-empty 'path' and 'target'.");
        }

        $normalized = [
            'path' => $path,
            'target' => $target,
            'module_id' => $moduleId,
        ];

        if (array_key_exists('public', $rawRoute)) {
            $normalized['public'] = (bool) $rawRoute['public'];
        }

        return $normalized;
    }

    /**
     * @return array<string, mixed>
     */
    private function normalizeUiSlotContribution(string $slotName, mixed $rawContribution, string $moduleId, string $basePath): array
    {
        if (!is_array($rawContribution)) {
            throw new RuntimeException(
                "UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must be an array."
            );
        }

        $key = trim((string) ($rawContribution['key'] ?? ''));
        if ($key === '') {
            throw new RuntimeException(
                "UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must define a non-empty 'key'."
            );
        }

        // Resolve relative template paths to absolute using the module's basePath.
        foreach (['panel_template', 'template'] as $templateKey) {
            if (!isset($rawContribution[$templateKey])) {
                continue;
            }
            $templatePath = trim((string) $rawContribution[$templateKey]);
            if ($templatePath !== '' && !str_starts_with($templatePath, '/')) {
                $rawContribution[$templateKey] = rtrim($basePath, '/') . '/' . $templatePath;
            }
        }

        $normalized = $rawContribution;
        $normalized['key'] = $key;
        $normalized['order'] = (int) ($rawContribution['order'] ?? 100);
        $normalized['__module_id'] = $moduleId;
        $normalized['module_id'] = $moduleId;

        return $normalized;
    }

    // ─── Getters ────────────────────────────────────────────────────────

    /** @return array<string, ModuleManifest> */
    public function getModules(): array
    {
        return $this->modules;
    }

    public function hasModule(string $id): bool
    {
        return isset($this->modules[$id]);
    }

    public function getModule(string $id): ModuleManifest
    {
        if (!isset($this->modules[$id])) {
            throw new RuntimeException("Module '{$id}' is not registered.");
        }
        return $this->modules[$id];
    }

    /** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
    public function getRoutes(): array
    {
        return $this->mergedRoutes;
    }

    /** @return list<string> */
    public function getPublicPaths(): array
    {
        return $this->mergedPublicPaths;
    }

    /** @return list<class-string> */
    public function getContainerRegistrars(): array
    {
        return $this->mergedContainerRegistrars;
    }

    /** @return array<string, list<mixed>> */
    public function getUiSlots(): array
    {
        return $this->mergedUiSlots;
    }

    /**
     * @return list<mixed> Contributions for a specific slot
     */
    public function getSlotContributions(string $slotName): array
    {
        return $this->mergedUiSlots[$slotName] ?? [];
    }

    /** @return list<class-string> */
    public function getSearchResources(): array
    {
        return $this->mergedSearchResources;
    }

    /** @return array<string, list<string>> */
    public function getAssetGroups(): array
    {
        return $this->mergedAssetGroups;
    }

    /** @return list<class-string> */
    public function getLayoutContextProviders(): array
    {
        return $this->mergedLayoutContextProviders;
    }

    /** @return list<class-string> */
    public function getSessionProviders(): array
    {
        return $this->mergedSessionProviders;
    }

    /**
     * @return list<array{key: string, description: string, active: int, is_system: int}>
     */
    public function getPermissions(): array
    {
        return $this->mergedPermissions;
    }

    /** @return list<string> */
    public function getPermissionKeys(): array
    {
        return array_map(
            static fn (array $permission): string => (string) $permission['key'],
            $this->mergedPermissions
        );
    }

    /** @return list<class-string> */
    public function getAuthorizationPolicies(): array
    {
        return $this->mergedAuthorizationPolicies;
    }

    /**
     * Collect unique ability strings from all module UI slot contributions.
     *
     * Used by UiAccessService to resolve module abilities into the layout auth map.
     * Each ability maps to itself (key = ability, value = ability).
     *
     * @return array<string, string>
     */
    public function getModuleUiAbilities(): array
    {
        $abilities = [];
        foreach ($this->mergedUiSlots as $items) {
            foreach ($items as $item) {
                $ability = trim((string) ($item['permission'] ?? ''));
                if ($ability !== '') {
                    $abilities[$ability] = $ability;
                }
            }
        }
        return $abilities;
    }

    /**
     * @return list<array{
     *     job_key: string,
     *     handler: string,
     *     label: string,
     *     description: string,
     *     default_enabled: int,
     *     default_timezone: string,
     *     default_schedule_type: string,
     *     default_schedule_interval: int,
     *     default_schedule_time: ?string,
     *     default_schedule_weekdays_csv: ?string,
     *     default_catchup_once: int,
     *     allowed_schedule_types: list<string>,
     *     module_id: string
     * }>
     */
    public function getSchedulerJobs(): array
    {
        return $this->mergedSchedulerJobs;
    }

    /** @return array<string, list<array{class: class-string, method: string}>> */
    public function getEventListeners(): array
    {
        return $this->mergedEventListeners;
    }
}
