<?php

namespace MintyPHP\Tests\App\Module;

use MintyPHP\App\Module\ModuleRegistry;
use PHPUnit\Framework\TestCase;
use RuntimeException;

final class ModuleRegistryTest extends TestCase
{
    private string $fixturesDir;

    protected function setUp(): void
    {
        $this->fixturesDir = sys_get_temp_dir() . '/corecore-module-test-' . uniqid();
        mkdir($this->fixturesDir, 0777, true);
    }

    protected function tearDown(): void
    {
        $this->removeDir($this->fixturesDir);
    }

    public function testBootWithNoModules(): void
    {
        $registry = ModuleRegistry::boot($this->fixturesDir, []);

        self::assertSame([], $registry->getModules());
        self::assertSame([], $registry->getRoutes());
        self::assertSame([], $registry->getPermissions());
    }

    public function testBootWithNonExistentDir(): void
    {
        $registry = ModuleRegistry::boot(sys_get_temp_dir() . '/does-not-exist-' . uniqid(), ['foo']);

        self::assertSame([], $registry->getModules());
    }

    public function testBootLoadsSingleModule(): void
    {
        $this->createModuleManifest('testmod', [
            'id' => 'testmod',
            'version' => '1.0.0',
            'load_order' => 10,
            'permissions' => [
                ['key' => 'test.view', 'description' => 'Test view'],
                ['key' => 'test.edit', 'description' => 'Test edit'],
            ],
            'search_resources' => [],
        ]);

        $registry = ModuleRegistry::boot($this->fixturesDir, ['testmod']);

        self::assertTrue($registry->hasModule('testmod'));
        self::assertSame('1.0.0', $registry->getModule('testmod')->version);
        self::assertSame(['test.view', 'test.edit'], $registry->getPermissionKeys());
    }

    public function testDeterministicOrdering(): void
    {
        $this->createModuleManifest('beta', ['id' => 'beta', 'load_order' => 20]);
        $this->createModuleManifest('alpha', ['id' => 'alpha', 'load_order' => 10]);
        $this->createModuleManifest('gamma', ['id' => 'gamma', 'load_order' => 10]);

        $registry = ModuleRegistry::boot($this->fixturesDir, ['beta', 'alpha', 'gamma']);
        $ids = array_keys($registry->getModules());

        // load_order 10: alpha, gamma (alpha < gamma by id); then load_order 20: beta
        self::assertSame(['alpha', 'gamma', 'beta'], $ids);
    }

    public function testDuplicateModuleIdThrows(): void
    {
        $this->createModuleManifest('dup', ['id' => 'dup']);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage("Duplicate module id: 'dup'");

        // Pass same ID twice — registry should fail-fast
        ModuleRegistry::boot($this->fixturesDir, ['dup', 'dup']);
    }

    public function testPermissionConflictThrows(): void
    {
        $this->createModuleManifest('mod-a', [
            'id' => 'mod-a',
            'load_order' => 1,
            'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission']],
        ]);
        $this->createModuleManifest('mod-b', [
            'id' => 'mod-b',
            'load_order' => 2,
            'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission duplicate']],
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage("Permission conflict: 'shared.perm'");

        ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
    }

    public function testRouteConflictThrows(): void
    {
        $this->createModuleManifest('mod-a', [
            'id' => 'mod-a',
            'load_order' => 1,
            'routes' => [['path' => '/shared', 'target' => 'shared/index']],
        ]);
        $this->createModuleManifest('mod-b', [
            'id' => 'mod-b',
            'load_order' => 2,
            'routes' => [['path' => '/shared-alt', 'target' => 'shared/index']],
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Route target conflict');

        ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
    }

    public function testRoutePathConflictThrows(): void
    {
        $this->createModuleManifest('mod-a', [
            'id' => 'mod-a',
            'load_order' => 1,
            'routes' => [['path' => '/shared-path', 'target' => 'a/index']],
        ]);
        $this->createModuleManifest('mod-b', [
            'id' => 'mod-b',
            'load_order' => 2,
            'routes' => [['path' => '/shared-path', 'target' => 'b/index']],
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Route path conflict');

        ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
    }

    public function testPublicRouteAddsPathAndTargetToPublicPaths(): void
    {
        $this->createModuleManifest('mod-public', [
            'id' => 'mod-public',
            'routes' => [
                ['path' => 'public/entry', 'target' => 'public/internal', 'public' => true],
            ],
        ]);

        $registry = ModuleRegistry::boot($this->fixturesDir, ['mod-public']);

        self::assertContains('public/entry', $registry->getPublicPaths());
        self::assertContains('public/internal', $registry->getPublicPaths());
    }

    public function testUiSlotConflictThrows(): void
    {
        $panelTemplateA = $this->createFixtureFile('mod-a-panel.phtml', '<ul></ul>');
        $panelTemplateB = $this->createFixtureFile('mod-b-panel.phtml', '<ul></ul>');

        $this->createModuleManifest('mod-a', [
            'id' => 'mod-a',
            'load_order' => 1,
            'ui_slots' => [
                'aside.tab_panel' => [
                    [
                        'key' => 'people',
                        'label' => 'People',
                        'icon' => 'bi-people',
                        'permission' => 'can_view_people',
                        'panel_template' => $panelTemplateA,
                    ],
                ],
            ],
        ]);
        $this->createModuleManifest('mod-b', [
            'id' => 'mod-b',
            'load_order' => 2,
            'ui_slots' => [
                'aside.tab_panel' => [
                    [
                        'key' => 'people',
                        'label' => 'People B',
                        'icon' => 'bi-people',
                        'permission' => 'can_view_people',
                        'panel_template' => $panelTemplateB,
                    ],
                ],
            ],
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage("UI slot conflict: slot 'aside.tab_panel' key 'people'");

        ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
    }

    public function testInvalidRouteContractThrows(): void
    {
        $this->createModuleManifest('mod-a', [
            'id' => 'mod-a',
            'routes' => [['path' => '', 'target' => 'address-book/index']],
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Manifest schema validation failed');

        ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
    }

    public function testInvalidUiSlotContractThrows(): void
    {
        $this->createModuleManifest('mod-a', [
            'id' => 'mod-a',
            'ui_slots' => [
                'aside.tab_panel' => [
                    ['label' => 'Missing Key'],
                ],
            ],
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Manifest schema validation failed');

        ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
    }

    public function testUiSlotsAreSortedDeterministicallyByOrderModuleAndKey(): void
    {
        $this->createModuleManifest('mod-z', [
            'id' => 'mod-z',
            'load_order' => 1,
            'ui_slots' => [
                'search.resource_item' => [
                    ['key' => 'z-second', 'label' => 'Z Second', 'base_url' => 'z-second', 'permission' => 'can.z', 'order' => 20],
                    ['key' => 'z-first', 'label' => 'Z First', 'base_url' => 'z-first', 'permission' => 'can.z', 'order' => 20],
                ],
            ],
        ]);
        $this->createModuleManifest('mod-a', [
            'id' => 'mod-a',
            'load_order' => 1,
            'ui_slots' => [
                'search.resource_item' => [
                    ['key' => 'a-mid', 'label' => 'A Mid', 'base_url' => 'a-mid', 'permission' => 'can.a', 'order' => 20],
                ],
            ],
        ]);
        $this->createModuleManifest('mod-b', [
            'id' => 'mod-b',
            'load_order' => 1,
            'ui_slots' => [
                'search.resource_item' => [
                    ['key' => 'b-low', 'label' => 'B Low', 'base_url' => 'b-low', 'permission' => 'can.b', 'order' => 10],
                ],
            ],
        ]);

        $registry = ModuleRegistry::boot($this->fixturesDir, ['mod-z', 'mod-b', 'mod-a']);
        $slotItems = $registry->getSlotContributions('search.resource_item');

        self::assertSame(['b-low', 'a-mid', 'z-first', 'z-second'], array_column($slotItems, 'key'));
        foreach ($slotItems as $slotItem) {
            self::assertArrayNotHasKey('__module_id', $slotItem);
        }
    }

    public function testMissingManifestFileThrows(): void
    {
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage("manifest not found");

        ModuleRegistry::boot($this->fixturesDir, ['nonexistent']);
    }

    public function testManifestIdMismatchThrows(): void
    {
        $this->createModuleManifest('wrongdir', ['id' => 'actualid']);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage("does not match manifest id 'actualid'");

        ModuleRegistry::boot($this->fixturesDir, ['wrongdir']);
    }

    public function testResolveEnabledModulesFromConfigWhenEnvUnset(): void
    {
        putenv('APP_ENABLED_MODULES');
        try {
            $result = ModuleRegistry::resolveEnabledModules([
                'enabled_modules' => ['mod-a', 'mod-b'],
            ]);

            self::assertSame(['mod-a', 'mod-b'], $result);
        } finally {
            putenv('APP_ENABLED_MODULES');
        }
    }

    public function testResolveEnabledModulesFromEnvList(): void
    {
        putenv('APP_ENABLED_MODULES=env-a,env-b');

        try {
            $result = ModuleRegistry::resolveEnabledModules([
                'enabled_modules' => ['config-a'],
            ]);
            self::assertSame(['env-a', 'env-b'], $result);
        } finally {
            putenv('APP_ENABLED_MODULES');
        }
    }

    public function testResolveEnabledModulesFromEmptyEnvReturnsNoModules(): void
    {
        putenv('APP_ENABLED_MODULES=');

        try {
            $result = ModuleRegistry::resolveEnabledModules([
                'enabled_modules' => ['config-a', 'config-b'],
            ]);
            self::assertSame([], $result);
        } finally {
            putenv('APP_ENABLED_MODULES');
        }
    }

    public function testResolveEnabledModulesDeduplicates(): void
    {
        putenv('APP_ENABLED_MODULES');
        try {
            $result = ModuleRegistry::resolveEnabledModules([
                'enabled_modules' => ['mod-a', 'mod-a', 'mod-b'],
            ]);

            self::assertSame(['mod-a', 'mod-b'], $result);
        } finally {
            putenv('APP_ENABLED_MODULES');
        }
    }

    public function testGetNonExistentModuleThrows(): void
    {
        $registry = ModuleRegistry::boot($this->fixturesDir, []);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage("Module 'nope' is not registered");

        $registry->getModule('nope');
    }

    public function testMergedContributions(): void
    {
        $this->createModuleManifest('mod-a', [
            'id' => 'mod-a',
            'load_order' => 1,
            'layout_context_providers' => ['ProviderA'],
            'session_providers' => ['SessionA'],
            'search_resources' => ['SearchA'],
            'asset_groups' => ['group-a' => ['a.css']],
        ]);
        $this->createModuleManifest('mod-b', [
            'id' => 'mod-b',
            'load_order' => 2,
            'layout_context_providers' => ['ProviderB'],
            'session_providers' => ['SessionB'],
            'search_resources' => ['SearchB'],
            'asset_groups' => ['group-b' => ['b.css']],
        ]);

        $registry = ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);

        self::assertSame(['ProviderA', 'ProviderB'], $registry->getLayoutContextProviders());
        self::assertSame(['SessionA', 'SessionB'], $registry->getSessionProviders());
        self::assertSame(['SearchA', 'SearchB'], $registry->getSearchResources());
        self::assertSame(['group-a' => ['a.css'], 'group-b' => ['b.css']], $registry->getAssetGroups());
    }

    // ── Helpers ──────────────────────────────────────────────────────

    /**
     * @param array<string, mixed> $manifest
     */
    private function createModuleManifest(string $dirName, array $manifest): void
    {
        $dir = $this->fixturesDir . '/' . $dirName;
        if (!is_dir($dir)) {
            mkdir($dir, 0777, true);
        }
        file_put_contents(
            $dir . '/module.php',
            '<?php return ' . var_export($manifest, true) . ';'
        );
    }

    private function createFixtureFile(string $relativePath, string $content): string
    {
        $path = $this->fixturesDir . '/' . ltrim($relativePath, '/');
        $dir = dirname($path);
        if (!is_dir($dir)) {
            mkdir($dir, 0777, true);
        }
        file_put_contents($path, $content);
        return $path;
    }

    private function removeDir(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }
        $items = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($items as $item) {
            if ($item->isDir()) {
                rmdir($item->getPathname());
            } else {
                unlink($item->getPathname());
            }
        }
        rmdir($dir);
    }
}
