<?php

namespace MintyPHP\App\Module;

use InvalidArgumentException;

/**
 * Immutable value object describing a single module's contributions.
 *
 * Each module ships a `module.php` that returns an associative array.
 * ModuleManifest validates and normalises that array into a typed contract.
 */
final class ModuleManifest
{
    public readonly string $id;
    public readonly string $version;
    public readonly bool $enabledByDefault;
    public readonly int $loadOrder;

    /** @var array<int, array{path: string, target: string, public?: bool}> */
    public readonly array $routes;

    /** @var list<string> */
    public readonly array $publicPaths;

    /** @var list<class-string> Container registrar FQCNs */
    public readonly array $containerRegistrars;

    /** @var array<string, mixed> Keyed by slot name (e.g. 'aside.tab_panel') */
    public readonly array $uiSlots;

    /** @var list<class-string> SearchResourceProvider FQCNs */
    public readonly array $searchResources;

    /** @var array<string, list<string>> Asset group name → file list */
    public readonly array $assetGroups;

    /**
     * @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>
     * }>
     */
    public readonly array $schedulerJobs;

    /** @var list<class-string> LayoutContextProvider FQCNs */
    public readonly array $layoutContextProviders;

    /** @var list<class-string> SessionProvider FQCNs */
    public readonly array $sessionProviders;

    /**
     * @var list<array{
     *     key: string,
     *     description: string,
     *     active: int,
     *     is_system: int
     * }>
     */
    public readonly array $permissions;

    /** @var list<class-string> AuthorizationPolicyInterface FQCNs */
    public readonly array $authorizationPolicies;

    /** @var list<string> Module IDs required by this module */
    public readonly array $requires;

    /** @var array<string, list<array{class: class-string, method: string}>> Event listeners keyed by event name */
    public readonly array $eventListeners;

    /** @var class-string|null ModuleDeactivationHandler FQCN */
    public readonly ?string $deactivationHandler;

    public readonly ?string $migrationsPath;

    public readonly ?string $i18nPath;

    public readonly string $basePath;

    /**
     * @param array<string, mixed> $raw The manifest array returned by module.php
     * @param string $basePath Absolute path to the module directory
     */
    private function __construct(array $raw, string $basePath)
    {
        $this->basePath = $basePath;

        // — required fields ————————————————————————————
        if (!isset($raw['id']) || !is_string($raw['id']) || trim($raw['id']) === '') {
            throw new InvalidArgumentException('Module manifest must have a non-empty string "id".');
        }
        $this->id = trim($raw['id']);

        $this->version = isset($raw['version']) && is_string($raw['version']) ? trim($raw['version']) : '0.0.0';
        $this->enabledByDefault = (bool) ($raw['enabled_by_default'] ?? false);
        $this->loadOrder = (int) ($raw['load_order'] ?? 100);

        // — optional contribution arrays ———————————————
        $this->routes = self::arrayOf($raw, 'routes');
        $this->publicPaths = self::listOf($raw, 'public_paths');
        $this->containerRegistrars = self::listOf($raw, 'container_registrars');
        $this->uiSlots = is_array($raw['ui_slots'] ?? null) ? $raw['ui_slots'] : [];
        $this->searchResources = self::listOf($raw, 'search_resources');
        $this->assetGroups = is_array($raw['asset_groups'] ?? null) ? $raw['asset_groups'] : [];
        $this->schedulerJobs = self::normalizeSchedulerJobs($raw['scheduler_jobs'] ?? [], $this->id);
        $this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
        $this->sessionProviders = self::listOf($raw, 'session_providers');
        $this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
        $this->authorizationPolicies = self::listOf($raw, 'authorization_policies');

        $this->requires = self::normalizeRequires($raw['requires'] ?? [], $this->id);
        $this->eventListeners = self::normalizeEventListeners($raw['event_listeners'] ?? [], $this->id);

        $deactivationHandler = $raw['deactivation_handler'] ?? null;
        $this->deactivationHandler = is_string($deactivationHandler) && trim($deactivationHandler) !== ''
            ? trim($deactivationHandler)
            : null;

        $migrationsPath = $raw['migrations_path'] ?? null;
        $this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
            ? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
            : null;

        $i18nPath = $raw['i18n_path'] ?? null;
        $this->i18nPath = is_string($i18nPath) && trim($i18nPath) !== ''
            ? rtrim($basePath, '/') . '/' . ltrim(trim($i18nPath), '/')
            : null;
    }

    /**
     * @param array<string, mixed> $raw
     */
    public static function fromArray(array $raw, string $basePath): self
    {
        return new self($raw, $basePath);
    }

    /**
     * @return list<mixed>
     */
    private static function listOf(array $raw, string $key): array
    {
        $value = $raw[$key] ?? [];
        return is_array($value) ? array_values($value) : [];
    }

    /**
     * @return array<int|string, mixed>
     */
    private static function arrayOf(array $raw, string $key): array
    {
        $value = $raw[$key] ?? [];
        return is_array($value) ? $value : [];
    }

    /**
     * @return list<array{key: string, description: string, active: int, is_system: int}>
     */
    private static function normalizePermissions(mixed $value, string $moduleId): array
    {
        if (!is_array($value)) {
            throw new InvalidArgumentException(
                sprintf("Module '%s' manifest key 'permissions' must be an array.", $moduleId)
            );
        }

        $permissions = [];
        foreach (array_values($value) as $index => $permission) {
            if (!is_array($permission)) {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' permissions[%d] must be an object-like array.", $moduleId, $index)
                );
            }

            $key = trim((string) ($permission['key'] ?? ''));
            $description = trim((string) ($permission['description'] ?? ''));
            if ($key === '' || $description === '') {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' permissions[%d] must define non-empty 'key' and 'description'.", $moduleId, $index)
                );
            }

            $permissions[] = [
                'key' => $key,
                'description' => $description,
                'active' => (int) ((bool) ($permission['active'] ?? true)),
                'is_system' => (int) ((bool) ($permission['is_system'] ?? true)),
            ];
        }

        return $permissions;
    }

    /**
     * @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>
     * }>
     */
    private static function normalizeSchedulerJobs(mixed $value, string $moduleId): array
    {
        if (!is_array($value)) {
            throw new InvalidArgumentException(
                sprintf("Module '%s' manifest key 'scheduler_jobs' must be an array.", $moduleId)
            );
        }

        $jobs = [];
        foreach (array_values($value) as $index => $job) {
            if (!is_array($job)) {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' scheduler_jobs[%d] must be an object-like array.", $moduleId, $index)
                );
            }

            $requiredKeys = [
                'job_key',
                'handler',
                'label',
                'description',
                'default_enabled',
                'default_timezone',
                'default_schedule_type',
                'default_schedule_interval',
                'default_schedule_time',
                'default_schedule_weekdays_csv',
                'default_catchup_once',
                'allowed_schedule_types',
            ];
            foreach ($requiredKeys as $requiredKey) {
                if (!array_key_exists($requiredKey, $job)) {
                    throw new InvalidArgumentException(
                        sprintf("Module '%s' scheduler_jobs[%d] is missing required key '%s'.", $moduleId, $index, $requiredKey)
                    );
                }
            }

            $jobKey = trim((string) ($job['job_key'] ?? ''));
            $handler = trim((string) ($job['handler'] ?? ''));
            $label = trim((string) ($job['label'] ?? ''));
            $description = trim((string) ($job['description'] ?? ''));
            $defaultTimezone = trim((string) ($job['default_timezone'] ?? ''));
            $defaultType = strtolower(trim((string) ($job['default_schedule_type'] ?? '')));

            if ($jobKey === '' || $handler === '' || $label === '' || $description === '' || $defaultTimezone === '') {
                throw new InvalidArgumentException(
                    sprintf(
                        "Module '%s' scheduler_jobs[%d] requires non-empty job_key, handler, label, description and default_timezone.",
                        $moduleId,
                        $index
                    )
                );
            }

            if (!in_array($defaultType, ['hourly', 'daily', 'weekly'], true)) {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' scheduler_jobs[%d] has invalid default_schedule_type '%s'.", $moduleId, $index, $defaultType)
                );
            }

            $allowedTypes = self::normalizeAllowedScheduleTypes($job['allowed_schedule_types'], $moduleId, $index);
            if (!in_array($defaultType, $allowedTypes, true)) {
                throw new InvalidArgumentException(
                    sprintf(
                        "Module '%s' scheduler_jobs[%d] default_schedule_type '%s' must be listed in allowed_schedule_types.",
                        $moduleId,
                        $index,
                        $defaultType
                    )
                );
            }

            $defaultInterval = (int) $job['default_schedule_interval'];
            $defaultTimeRaw = trim((string) ($job['default_schedule_time'] ?? ''));
            $defaultTime = $defaultTimeRaw !== '' ? $defaultTimeRaw : null;
            $defaultWeekdays = self::normalizeWeekdaysCsv(
                $job['default_schedule_weekdays_csv'],
                $moduleId,
                $index
            );

            if (in_array($defaultType, ['daily', 'weekly'], true) && $defaultTime === null) {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_time for %s jobs.", $moduleId, $index, $defaultType)
                );
            }
            if ($defaultType === 'weekly' && $defaultWeekdays === null) {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_weekdays_csv for weekly jobs.", $moduleId, $index)
                );
            }

            $jobs[] = [
                'job_key' => $jobKey,
                'handler' => $handler,
                'label' => $label,
                'description' => $description,
                'default_enabled' => (int) ((bool) $job['default_enabled']),
                'default_timezone' => $defaultTimezone,
                'default_schedule_type' => $defaultType,
                'default_schedule_interval' => $defaultInterval,
                'default_schedule_time' => $defaultTime,
                'default_schedule_weekdays_csv' => $defaultWeekdays,
                'default_catchup_once' => (int) ((bool) $job['default_catchup_once']),
                'allowed_schedule_types' => $allowedTypes,
            ];
        }

        return $jobs;
    }

    /**
     * @return list<string>
     */
    private static function normalizeAllowedScheduleTypes(mixed $value, string $moduleId, int $index): array
    {
        if (!is_array($value) || $value === []) {
            throw new InvalidArgumentException(
                sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types must be a non-empty array.", $moduleId, $index)
            );
        }

        $types = [];
        foreach ($value as $rawType) {
            $type = strtolower(trim((string) $rawType));
            if (!in_array($type, ['hourly', 'daily', 'weekly'], true)) {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types contains invalid type '%s'.", $moduleId, $index, $type)
                );
            }
            $types[] = $type;
        }

        $types = array_values(array_unique($types));
        sort($types, SORT_STRING);
        return $types;
    }

    /**
     * @return array<string, list<array{class: class-string, method: string}>>
     */
    private static function normalizeEventListeners(mixed $value, string $moduleId): array
    {
        if (!is_array($value)) {
            throw new InvalidArgumentException(
                sprintf("Module '%s' manifest key 'event_listeners' must be an array.", $moduleId)
            );
        }

        $listeners = [];
        foreach ($value as $eventName => $handlers) {
            $eventName = trim((string) $eventName);
            if ($eventName === '') {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' event_listeners has an empty event name key.", $moduleId)
                );
            }

            if (!is_array($handlers)) {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' event_listeners['%s'] must be an array of handler descriptors.", $moduleId, $eventName)
                );
            }

            $normalizedHandlers = [];
            foreach (array_values($handlers) as $index => $handler) {
                if (!is_array($handler)) {
                    throw new InvalidArgumentException(
                        sprintf("Module '%s' event_listeners['%s'][%d] must be an array with 'class' and 'method'.", $moduleId, $eventName, $index)
                    );
                }

                $class = trim((string) ($handler['class'] ?? ''));
                $method = trim((string) ($handler['method'] ?? ''));
                if ($class === '' || $method === '') {
                    throw new InvalidArgumentException(
                        sprintf("Module '%s' event_listeners['%s'][%d] must define non-empty 'class' and 'method'.", $moduleId, $eventName, $index)
                    );
                }

                $normalizedHandlers[] = ['class' => $class, 'method' => $method];
            }

            if ($normalizedHandlers !== []) {
                $listeners[$eventName] = $normalizedHandlers;
            }
        }

        return $listeners;
    }

    /**
     * @return list<string>
     */
    private static function normalizeRequires(mixed $value, string $moduleId): array
    {
        if (!is_array($value)) {
            throw new InvalidArgumentException(
                sprintf("Module '%s' manifest key 'requires' must be an array.", $moduleId)
            );
        }

        $requires = [];
        foreach (array_values($value) as $index => $item) {
            if (!is_string($item) || trim($item) === '') {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' requires[%d] must be a non-empty string.", $moduleId, $index)
                );
            }
            $requires[] = trim($item);
        }

        return array_values(array_unique($requires));
    }

    private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
    {
        $raw = trim((string) $value);
        if ($raw === '') {
            return null;
        }

        $days = array_filter(array_map('trim', explode(',', $raw)), static fn (string $part): bool => $part !== '');
        if ($days === []) {
            return null;
        }

        $normalizedDays = [];
        foreach ($days as $day) {
            if (!preg_match('/^[1-7]$/', $day)) {
                throw new InvalidArgumentException(
                    sprintf("Module '%s' scheduler_jobs[%d] has invalid weekday '%s' in default_schedule_weekdays_csv.", $moduleId, $index, $day)
                );
            }
            $normalizedDays[] = (int) $day;
        }

        $normalizedDays = array_values(array_unique($normalizedDays));
        sort($normalizedDays, SORT_NUMERIC);
        return implode(',', array_map(static fn (int $day): string => (string) $day, $normalizedDays));
    }
}
