<?php

namespace MintyPHP\Tests\Architecture;

use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\App\Module\ModuleManifest;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use PHPUnit\Framework\TestCase;

/**
 * Architecture test: enforces structural conventions for ALL modules under modules/.
 *
 * Every module directory must pass these checks. This ensures consistency
 * when new modules are added and prevents structural drift.
 */
final class ModuleStructureContractTest extends TestCase
{
    use ProjectFileAssertionSupport;

    /** @var list<array{id: string, path: string, manifest: ModuleManifest, raw: array<string, mixed>}> */
    private static array $modules = [];

    public static function setUpBeforeClass(): void
    {
        $modulesDir = realpath(__DIR__ . '/../../modules');
        if ($modulesDir === false || !is_dir($modulesDir)) {
            return;
        }

        $entries = scandir($modulesDir);
        if ($entries === false) {
            return;
        }

        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
                continue;
            }

            $manifestFile = $modulesDir . '/' . $entry . '/module.php';
            if (!is_file($manifestFile)) {
                continue;
            }

            $raw = require $manifestFile;
            if (!is_array($raw)) {
                continue;
            }

            $manifest = ModuleManifest::fromArray($raw, $modulesDir . '/' . $entry);
            self::$modules[] = [
                'id' => $entry,
                'path' => $modulesDir . '/' . $entry,
                'manifest' => $manifest,
                'raw' => $raw,
            ];
        }
    }

    // ─── Manifest structure ──────────────────────────────────────────

    public function testAtLeastOneModuleExists(): void
    {
        self::assertNotEmpty(self::$modules, 'Expected at least one module under modules/');
    }

    public function testEveryModuleHasManifest(): void
    {
        $modulesDir = $this->projectRootPath() . '/modules';
        if (!is_dir($modulesDir)) {
            self::markTestSkipped('No modules/ directory');
        }

        $entries = scandir($modulesDir) ?: [];
        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
                continue;
            }
            self::assertFileExists(
                $modulesDir . '/' . $entry . '/module.php',
                "Module directory '{$entry}' is missing module.php manifest"
            );
        }
    }

    public function testManifestIdMatchesDirectoryName(): void
    {
        foreach (self::$modules as $module) {
            self::assertSame(
                $module['id'],
                $module['manifest']->id,
                "Module directory '{$module['id']}' has mismatched manifest id '{$module['manifest']->id}'"
            );
        }
    }

    public function testManifestHasVersion(): void
    {
        foreach (self::$modules as $module) {
            self::assertMatchesRegularExpression(
                '/^\d+\.\d+\.\d+$/',
                $module['manifest']->version,
                "Module '{$module['id']}' must have a semver version (got '{$module['manifest']->version}')"
            );
        }
    }

    public function testManifestHasAllRequiredKeys(): void
    {
        $requiredKeys = [
            'id', 'version', 'enabled_by_default', 'load_order',
            'routes', 'public_paths', 'container_registrars',
            'ui_slots', 'search_resources', 'asset_groups',
            'scheduler_jobs', 'layout_context_providers',
            'session_providers', 'permissions',
        ];

        foreach (self::$modules as $module) {
            foreach ($requiredKeys as $key) {
                self::assertArrayHasKey(
                    $key,
                    $module['raw'],
                    "Module '{$module['id']}' manifest is missing required key '{$key}'"
                );
            }
        }
    }

    public function testRequiresFieldContainsOnlyValidModuleIds(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->requires as $index => $requiredId) {
                self::assertIsString($requiredId);
                self::assertNotEmpty(
                    trim($requiredId),
                    "Module '{$module['id']}' requires[{$index}] must be a non-empty string"
                );
                self::assertMatchesRegularExpression(
                    '/^[a-z][a-z0-9_-]*$/',
                    $requiredId,
                    "Module '{$module['id']}' requires[{$index}] has invalid module id format: '{$requiredId}'"
                );
            }
        }

        self::addToAssertionCount(1);
    }

    // ─── File structure ──────────────────────────────────────────────

    public function testModuleHasLibDirectory(): void
    {
        foreach (self::$modules as $module) {
            self::assertDirectoryExists(
                $module['path'] . '/lib',
                "Module '{$module['id']}' must have a lib/ directory for PHP classes"
            );
        }
    }

    // ─── Container registrars ────────────────────────────────────────

    public function testContainerRegistrarClassesExist(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->containerRegistrars as $registrar) {
                self::assertTrue(
                    class_exists($registrar),
                    "Module '{$module['id']}' declares container_registrar '{$registrar}' but class does not exist"
                );
            }
        }
    }

    // ─── Provider contracts ──────────────────────────────────────────

    public function testSearchProviderClassesImplementContract(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->searchResources as $providerClass) {
                self::assertTrue(
                    class_exists($providerClass),
                    "Module '{$module['id']}' search provider '{$providerClass}' class not found"
                );
                $provider = new $providerClass();
                self::assertInstanceOf(
                    SearchResourceProvider::class,
                    $provider,
                    "Module '{$module['id']}' search provider must implement SearchResourceProvider"
                );
            }
        }
    }

    public function testLayoutContextProviderClassesImplementContract(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->layoutContextProviders as $providerClass) {
                self::assertTrue(
                    class_exists($providerClass),
                    "Module '{$module['id']}' layout context provider '{$providerClass}' class not found"
                );
                $provider = new $providerClass();
                self::assertInstanceOf(
                    LayoutContextProvider::class,
                    $provider,
                    "Module '{$module['id']}' layout context provider must implement LayoutContextProvider"
                );
            }
        }
    }

    public function testSessionProviderClassesImplementContract(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->sessionProviders as $providerClass) {
                self::assertTrue(
                    class_exists($providerClass),
                    "Module '{$module['id']}' session provider '{$providerClass}' class not found"
                );
                $provider = new $providerClass();
                self::assertInstanceOf(
                    SessionProvider::class,
                    $provider,
                    "Module '{$module['id']}' session provider must implement SessionProvider"
                );
            }
        }
    }

    // ─── Authorization policies ──────────────────────────────────────

    public function testAuthorizationPolicyClassesImplementContract(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->authorizationPolicies as $policyClass) {
                self::assertTrue(
                    class_exists($policyClass),
                    "Module '{$module['id']}' authorization policy '{$policyClass}' class not found"
                );
                self::assertTrue(
                    is_subclass_of($policyClass, AuthorizationPolicyInterface::class)
                    || in_array(AuthorizationPolicyInterface::class, class_implements($policyClass) ?: [], true),
                    "Module '{$module['id']}' authorization policy must implement AuthorizationPolicyInterface"
                );
            }
        }
    }

    // ─── UI slot abilities ──────────────────────────────────────────

    /**
     * Every 'permission' value in a module's UI slots must reference an ability
     * declared as a constant on one of the module's authorization policies.
     */
    public function testUiSlotAbilitiesMatchPolicySupports(): void
    {
        foreach (self::$modules as $module) {
            // Collect all abilities supported by this module's policies
            $supportedAbilities = [];
            foreach ($module['manifest']->authorizationPolicies as $policyClass) {
                if (!class_exists($policyClass)) {
                    continue;
                }
                $reflection = new \ReflectionClass($policyClass);
                foreach ($reflection->getConstants() as $constValue) {
                    if (is_string($constValue)) {
                        $supportedAbilities[] = $constValue;
                    }
                }
            }

            if ($supportedAbilities === []) {
                continue;
            }

            // Check every permission value from UI slots
            foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
                $contributionList = is_array($contributions) ? $contributions : [$contributions];
                foreach ($contributionList as $idx => $contribution) {
                    if (!is_array($contribution)) {
                        continue;
                    }
                    $ability = trim((string) ($contribution['permission'] ?? ''));
                    if ($ability === '') {
                        continue;
                    }
                    self::assertContains(
                        $ability,
                        $supportedAbilities,
                        "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] permission '{$ability}' "
                        . "is not declared as a constant on any module authorization policy"
                    );
                }
            }
        }
    }

    // ─── Permissions contract ───────────────────────────────────────

    public function testPermissionsUseObjectContract(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->permissions as $index => $permission) {
                self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key");
                self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description");
                self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty");
                self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty");
                self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1");
                self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1");
            }
        }
    }

    // ─── Scheduler jobs contract ────────────────────────────────────

    public function testSchedulerJobsUseFullContractSchema(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->schedulerJobs as $index => $job) {
                $required = [
                    '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 ($required as $requiredKey) {
                    self::assertArrayHasKey(
                        $requiredKey,
                        $job,
                        "Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'"
                    );
                }

                self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty");
                self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty");
                self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty");
                self::assertContains(
                    (string) $job['default_schedule_type'],
                    ['hourly', 'daily', 'weekly'],
                    "Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid"
                );

                $allowedTypes = array_values($job['allowed_schedule_types']);
                self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty");
                foreach ($allowedTypes as $allowedType) {
                    self::assertContains(
                        (string) $allowedType,
                        ['hourly', 'daily', 'weekly'],
                        "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid"
                    );
                }
            }
        }

        self::addToAssertionCount(1);
    }

    // ─── UI slots ────────────────────────────────────────────────────

    public function testUiSlotContributionsHaveRequiredKeys(): void
    {
        $slotKeyRequirements = [
            'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
            'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
            'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
            'topbar.right_item' => ['key', 'template'],
            'layout.body_end_template' => ['key', 'template'],
            'layout.head_style' => ['key', 'path'],
            'runtime.component' => ['key', 'script'],
        ];

        foreach (self::$modules as $module) {
            foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
                if (!isset($slotKeyRequirements[$slotName])) {
                    continue;
                }

                $required = $slotKeyRequirements[$slotName];
                $contributionList = is_array($contributions) ? $contributions : [$contributions];

                foreach ($contributionList as $idx => $contribution) {
                    if (!is_array($contribution)) {
                        continue;
                    }
                    foreach ($required as $requiredKey) {
                        self::assertArrayHasKey(
                            $requiredKey,
                            $contribution,
                            "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'"
                        );
                    }
                }
            }
        }
    }

    public function testPanelTemplatesExist(): void
    {
        foreach (self::$modules as $module) {
            $templateSlots = [
                'aside.tab_panel' => 'panel_template',
                'topbar.right_item' => 'template',
                'layout.body_end_template' => 'template',
            ];

            foreach ($templateSlots as $slotName => $templateKey) {
                $contributions = $module['manifest']->uiSlots[$slotName] ?? [];
                if (!is_array($contributions)) {
                    continue;
                }
                foreach ($contributions as $contribution) {
                    if (!is_array($contribution)) {
                        continue;
                    }
                    $template = $contribution[$templateKey] ?? null;
                    if ($template === null) {
                        continue;
                    }
                    // Resolve relative paths against module basePath
                    $templatePath = (string) $template;
                    if (!str_starts_with($templatePath, '/')) {
                        $templatePath = $module['manifest']->basePath . '/' . $templatePath;
                    }
                    self::assertFileExists(
                        $templatePath,
                        "Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$templatePath}"
                    );
                }
            }
        }
    }

    public function testRuntimeComponentPhasesAreValid(): void
    {
        foreach (self::$modules as $module) {
            $runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? [];
            if (!is_array($runtimeComponents)) {
                continue;
            }

            foreach ($runtimeComponents as $index => $contribution) {
                if (!is_array($contribution)) {
                    continue;
                }

                $phase = strtolower(trim((string) ($contribution['phase'] ?? 'late')));
                self::assertContains(
                    $phase,
                    ['early', 'default', 'late'],
                    "Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'"
                );
            }
        }
    }

    // ─── Routes ──────────────────────────────────────────────────────

    public function testRoutesHavePathAndTarget(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->routes as $idx => $route) {
                self::assertArrayHasKey(
                    'path',
                    $route,
                    "Module '{$module['id']}' route[{$idx}] is missing 'path'"
                );
                self::assertArrayHasKey(
                    'target',
                    $route,
                    "Module '{$module['id']}' route[{$idx}] is missing 'target'"
                );
                self::assertNotEmpty(
                    trim((string) $route['path']),
                    "Module '{$module['id']}' route[{$idx}] has empty 'path'"
                );
                self::assertNotEmpty(
                    trim((string) $route['target']),
                    "Module '{$module['id']}' route[{$idx}] has empty 'target'"
                );
            }
        }
    }

    // ─── Pages directory ─────────────────────────────────────────────

    public function testModulesWithRoutesHavePagesDirectory(): void
    {
        foreach (self::$modules as $module) {
            if (empty($module['manifest']->routes)) {
                continue;
            }
            self::assertDirectoryExists(
                $module['path'] . '/pages',
                "Module '{$module['id']}' declares routes but has no pages/ directory"
            );
        }
    }

    // ─── Namespace ownership ───────────────────────────────────────

    /**
     * All PHP classes under modules/<id>/lib/ must use a MintyPHP\Module\<Name>\
     * namespace — never a Core namespace like MintyPHP\Service\ or MintyPHP\Repository\.
     *
     * This prevents module code from masquerading as core code and ensures
     * `grep MintyPHP\Module\` reliably finds all module-owned classes.
     */
    public function testModuleClassesUseModuleNamespace(): void
    {
        $coreNamespacePrefixes = [
            'MintyPHP\\Service\\',
            'MintyPHP\\Repository\\',
            'MintyPHP\\Support\\',
            'MintyPHP\\Http\\',
            'MintyPHP\\App\\Container\\',
        ];

        foreach (self::$modules as $module) {
            $libDir = $module['path'] . '/lib';
            if (!is_dir($libDir)) {
                continue;
            }

            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($libDir, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::LEAVES_ONLY
            );

            foreach ($iterator as $file) {
                if ($file->getExtension() !== 'php') {
                    continue;
                }

                $content = file_get_contents($file->getPathname());
                if ($content === false) {
                    continue;
                }

                if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) {
                    $namespace = $match[1];
                    foreach ($coreNamespacePrefixes as $corePrefix) {
                        self::assertFalse(
                            str_starts_with($namespace . '\\', $corePrefix),
                            "Module '{$module['id']}' class in {$file->getFilename()} uses core namespace '{$namespace}'. "
                            . "Module classes must use MintyPHP\\Module\\* namespace."
                        );
                    }
                }
            }
        }
    }

    // ─── Event listener contract ────────────────────────────────────────

    public function testEventListenerClassesImplementContract(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->eventListeners as $eventName => $handlers) {
                foreach ($handlers as $index => $handler) {
                    $class = $handler['class'];
                    self::assertTrue(
                        class_exists($class),
                        "Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' not found"
                    );
                    self::assertTrue(
                        is_subclass_of($class, EventListener::class)
                        || in_array(EventListener::class, class_implements($class) ?: [], true),
                        "Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' "
                        . "must implement EventListener interface"
                    );
                }
            }
        }

        self::addToAssertionCount(1);
    }

    // ─── Deactivation handler contract ──────────────────────────────

    public function testDeactivationHandlerClassImplementsContract(): void
    {
        foreach (self::$modules as $module) {
            $handler = $module['manifest']->deactivationHandler;
            if ($handler === null) {
                continue;
            }
            self::assertTrue(
                class_exists($handler),
                "Module '{$module['id']}' deactivation_handler class '{$handler}' not found"
            );
            self::assertTrue(
                is_subclass_of($handler, ModuleDeactivationHandler::class)
                || in_array(ModuleDeactivationHandler::class, class_implements($handler) ?: [], true),
                "Module '{$module['id']}' deactivation_handler class '{$handler}' "
                . "must implement ModuleDeactivationHandler interface"
            );
        }

        self::addToAssertionCount(1);
    }

    // ─── Session key convention ────────────────────────────────────────

    /**
     * Module session providers must use the 'module.<id>.' prefix for all session keys.
     *
     * Scans session provider classes for $_SESSION[ and ->set( patterns and verifies
     * that the key argument starts with 'module.<module_id>.'.
     */
    public function testSessionProvidersUseModulePrefixedKeys(): void
    {
        foreach (self::$modules as $module) {
            foreach ($module['manifest']->sessionProviders as $providerClass) {
                if (!class_exists($providerClass)) {
                    continue;
                }

                $reflection = new \ReflectionClass($providerClass);
                $file = $reflection->getFileName();
                if ($file === false) {
                    continue;
                }

                $content = file_get_contents($file);
                if ($content === false) {
                    continue;
                }

                $expectedPrefix = 'module.' . $module['id'] . '.';

                // Check $_SESSION['...'] writes and unset($_SESSION['...'])
                if (preg_match_all('/\$_SESSION\[\'([^\']+)\'\]/', $content, $matches)) {
                    foreach ($matches[1] as $key) {
                        self::assertStringStartsWith(
                            $expectedPrefix,
                            $key,
                            "Module '{$module['id']}' session provider '{$providerClass}' uses session key "
                            . "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
                        );
                    }
                }

                // Check ->set('...', ...) calls on session store
                if (preg_match_all('/->set\(\s*\'([^\']+)\'/', $content, $matches)) {
                    foreach ($matches[1] as $key) {
                        self::assertStringStartsWith(
                            $expectedPrefix,
                            $key,
                            "Module '{$module['id']}' session provider '{$providerClass}' uses session store key "
                            . "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
                        );
                    }
                }
            }
        }

        self::addToAssertionCount(1);
    }

    // ─── No hardcoded Core ability imports in module pages ───────────

    public function testModulePagesDoNotImportCoreAbilityConstants(): void
    {
        foreach (self::$modules as $module) {
            $pagesDir = $module['path'] . '/pages';
            if (!is_dir($pagesDir)) {
                continue;
            }

            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($pagesDir)
            );

            foreach ($iterator as $file) {
                if (!$file->isFile() || $file->getExtension() !== 'php') {
                    continue;
                }

                $content = file_get_contents($file->getPathname());
                if ($content === false) {
                    continue;
                }

                // Module pages should use their own policy constants, not Core's OperationsAuthorizationPolicy
                self::assertStringNotContainsString(
                    'OperationsAuthorizationPolicy::ABILITY_',
                    $content,
                    "Module '{$module['id']}' page '{$file->getFilename()}' imports Core OperationsAuthorizationPolicy ability. "
                    . "Use the module's own AuthorizationPolicy instead."
                );
            }
        }
    }
}
