<?php

declare(strict_types=1);

namespace Apexl\Io\Tests\FeatureTests;

use Apexl\Io\Module;
use Apexl\Io\TestInfrastructure\FeatureTest;
use PHPUnit\Framework\Attributes\Test;
use ReflectionClass;

/**
 * A module is configuration, not state: constructed once, it registers things and is done.
 *
 * `Module` is readonly, which PHP makes viral — a non-readonly class cannot extend a readonly
 * one — so this is enforced by the language for anything in this repository. The reason to
 * test it anyway is that the failure it prevents does not surface at compile time. A readonly
 * class holding `private array $cache` declares perfectly well and dies on the *second* write
 * with "Cannot indirectly modify readonly property" — which for a module memoising per entity
 * means boot, in whichever consuming app registers two of something.
 *
 * So this asserts the shape rather than the keyword: no module holds a property that a method
 * could write to twice. Memoisation belongs behind an object the property points at, the way
 * tabulatorModule's MenuMetadataCache does — a readonly property may not be reassigned, but
 * what it points at can be mutated all it likes.
 */
final class ModulesAreImmutableTest extends FeatureTest
{
    /** @return list<class-string<Module>> */
    private function modules(): array
    {
        $modules = array_filter(
            get_declared_classes(),
            static fn(string $class): bool => is_subclass_of($class, Module::class),
        );

        self::assertNotEmpty($modules, 'No modules are loaded; this test would pass vacuously.');

        return array_values($modules);
    }

    #[Test]
    public function everyModuleIsReadonly(): void
    {
        $mutable = array_filter(
            $this->modules(),
            static fn(string $class): bool => !new ReflectionClass($class)->isReadOnly(),
        );

        self::assertSame([], array_values($mutable));
    }

    /**
     * No module holds an array.
     *
     * Checking `isReadOnly()` would prove nothing: inside a readonly class every property is
     * implicitly readonly, so an array declared there reports as readonly and still cannot
     * have a key written to it. `$this->cache[$k] = $v` counts as reassigning the property
     * and raises "Cannot indirectly modify readonly property" — on the write, not the
     * declaration, which for a module memoising per entity means boot.
     *
     * An array on a readonly module is therefore either a constant wearing the wrong hat or a
     * cache that will fail the second time it is used. Both want to be something else: a
     * const, or an object the property points at — a readonly property may not be reassigned,
     * but what it points at may be mutated freely, which is what tabulatorModule's
     * MenuMetadataCache is for.
     */
    #[Test]
    public function noModuleHoldsAnArray(): void
    {
        $offenders = [];

        foreach ($this->modules() as $class) {
            foreach (new ReflectionClass($class)->getProperties() as $property) {
                if ($property->isStatic()) {
                    continue;
                }

                $type = $property->getType();

                if ($type instanceof \ReflectionNamedType && $type->getName() === 'array') {
                    $offenders[] = sprintf('%s::$%s', $property->getDeclaringClass()->getName(), $property->getName());
                }
            }
        }

        self::assertSame([], $offenders, 'An array on a readonly module cannot be added to; use a collection.');
    }

    /** Every module's constructor argument is promoted readonly or kept nowhere. */
    #[Test]
    public function everyModulePropertyIsTyped(): void
    {
        $untyped = [];

        foreach ($this->modules() as $class) {
            foreach (new ReflectionClass($class)->getProperties() as $property) {
                if (!$property->hasType()) {
                    $untyped[] = sprintf('%s::$%s', $property->getDeclaringClass()->getName(), $property->getName());
                }
            }
        }

        self::assertSame([], $untyped);
    }
}
