<?php

namespace MintyPHP\Tests\Architecture;

use PHPUnit\Framework\TestCase;
use ReflectionFunction;
use ReflectionNamedType;

/**
 * Freezes the API of the 5 orthogonal building blocks in
 * core/Support/helpers/action_context.php.
 *
 * Aggregators (actionEditContext, actionCreateContext) are intentionally NOT
 * frozen — they may evolve additively across the rollout.
 *
 * This test also asserts that the Tenant-Scope helper carries its PHPStan
 * array-shape return docblock. Acceptance check SC-004 relies on the shape
 * staying stable.
 *
 * Step 2 (Pilot-Migration Departments-Edit, Run
 * 2026-04-25-action-context-rollout-step2-departments) is the first
 * production caller. The previous testNoProductionCallSitesYet assertion
 * has been removed; the CSRF↔Aggregator pairing is enforced by
 * tests/Architecture/ActionContextCsrfPairingContractTest.php instead.
 *
 * Run 2026-04-25-action-fragment-context-removal pruned the dead
 * Cluster-10 Drawer-Fragment helpers (actionFragmentResolveOrStatus building
 * block + actionFragmentContext aggregator) after Step 11 declared all three
 * fragment actions structural exceptions that delegate to domain-services
 * with their own status models.
 */
class ActionContextHelperContractTest extends TestCase
{
    use ProjectFileAssertionSupport;

    /**
     * @return array<int, array{0: string, 1: list<array{0: string, 1?: mixed, 2?: bool}>}>
     */
    public static function buildingBlocks(): array
    {
        // [function-name, [[paramName, ?defaultValue, hasDefault], ...]]
        return [
            ['actionResolveModelOrFail', [
                ['finder', null, false],
                ['rawId', null, false],
                ['notFoundFlashKey', null, false],
                ['redirectPath', null, false],
            ]],
            ['actionAuthorizeAndExtractCapabilities', [
                ['abilityKey', null, false],
                ['context', null, false],
                ['forbiddenStrategy', 'redirect', true],
            ]],
            ['actionDeriveTenantScope', [
                ['capabilities', null, false],
                ['allTenantsFlagKey', 'can_manage_all_tenants', true],
                ['allowedListKey', 'allowed_tenant_ids', true],
            ]],
            ['actionEnforceCanViewPage', [
                ['capabilities', null, false],
                ['flagKey', 'can_view_page', true],
                ['forbiddenStrategy', 'redirect', true],
            ]],
            ['actionBuildViewAuth', [
                ['capabilities', null, false],
                ['whitelistedFlags', null, false],
            ]],
        ];
    }

    public function testHelperFileExistsAndIsRegistered(): void
    {
        $this->assertFileExists($this->projectRootPath() . '/core/Support/helpers/action_context.php');

        $helpersFile = $this->readProjectFile('core/Support/helpers.php');
        $this->assertStringContainsString(
            "require __DIR__ . '/helpers/action_context.php';",
            $helpersFile,
            'helpers/action_context.php must be required from core/Support/helpers.php'
        );
    }

    public function testBuildingBlocksExistWithStableSignatures(): void
    {
        foreach (self::buildingBlocks() as [$fnName, $expectedParams]) {
            $this->assertTrue(
                function_exists($fnName),
                "Helper function '{$fnName}' must exist (frozen by Step 1)."
            );

            $reflection = new ReflectionFunction($fnName);
            $actualParams = $reflection->getParameters();

            $this->assertSameSize(
                $expectedParams,
                $actualParams,
                "Parameter count for '{$fnName}' has changed."
            );

            foreach ($expectedParams as $i => [$expectedName, $expectedDefault, $hasDefault]) {
                $this->assertSame(
                    $expectedName,
                    $actualParams[$i]->getName(),
                    "Parameter #{$i} of '{$fnName}' has been renamed."
                );
                $this->assertSame(
                    $hasDefault,
                    $actualParams[$i]->isDefaultValueAvailable(),
                    "Parameter '{$expectedName}' of '{$fnName}' default-availability has changed."
                );
                if ($hasDefault) {
                    $this->assertSame(
                        $expectedDefault,
                        $actualParams[$i]->getDefaultValue(),
                        "Parameter '{$expectedName}' of '{$fnName}' default has changed."
                    );
                }
            }
        }
    }

    public function testTenantScopeReturnDocblockIsFrozen(): void
    {
        $reflection = new ReflectionFunction('actionDeriveTenantScope');
        $doc = (string) $reflection->getDocComment();

        $this->assertStringContainsString(
            "@return array{scope: 'all'|'list', ids: list<int>}",
            $doc,
            "actionDeriveTenantScope must declare PHPStan array-shape return for GR-SEC-009."
        );
    }

    public function testEnforceCanViewPageDocblockHasStrictComparisonNote(): void
    {
        $reflection = new ReflectionFunction('actionEnforceCanViewPage');
        $doc = (string) $reflection->getDocComment();

        $this->assertStringContainsString(
            'Strict comparison (=== true)',
            $doc,
            "actionEnforceCanViewPage docblock must document strict-comparison contract."
        );
        $this->assertStringContainsString(
            'No truthy coercion',
            $doc,
            "actionEnforceCanViewPage docblock must document no-truthy-coercion contract."
        );
    }

    public function testBuildViewAuthDocblockHasEscapeWarning(): void
    {
        $reflection = new ReflectionFunction('actionBuildViewAuth');
        $doc = (string) $reflection->getDocComment();

        $this->assertStringContainsString(
            'MUST e()-escape',
            $doc,
            "actionBuildViewAuth docblock must require e()-escaping per GR-SEC-010."
        );
    }

    public function testAggregatorDocblocksWarnAboutCsrf(): void
    {
        $contents = $this->readProjectFile('core/Support/helpers/action_context.php');

        $count = preg_match_all(
            '/MUST call actionRequireCsrf\(\) BEFORE this aggregator/',
            $contents
        );
        // Expected count is 2: actionEditContext + actionCreateContext.
        // Run 2026-04-25-action-fragment-context-removal removed the
        // actionFragmentContext aggregator (Cluster 10 has no aggregator —
        // drawer-fragments use domain-service status models instead), reducing
        // the warning count from 3 to 2.
        $this->assertSame(
            2,
            $count,
            'Each of the 2 aggregators (actionEditContext, actionCreateContext) must carry the explicit CSRF-warning in its docblock (GR-SEC-001).'
        );
    }

    public function testAggregatorsExistButAreNotFrozen(): void
    {
        // We assert existence only — signatures may evolve.
        // Run 2026-04-25-action-fragment-context-removal removed
        // actionFragmentContext; only EditContext and CreateContext remain.
        $this->assertTrue(function_exists('actionEditContext'));
        $this->assertTrue(function_exists('actionCreateContext'));
    }

    /**
     * Ensure the `mixed` return type of building blocks is preserved where
     * documented. Catches accidental tightening that would break Step 2.
     */
    public function testResolveModelOrFailReturnsMixed(): void
    {
        $reflection = new ReflectionFunction('actionResolveModelOrFail');
        $returnType = $reflection->getReturnType();
        $this->assertInstanceOf(ReflectionNamedType::class, $returnType);
        /** @var ReflectionNamedType $returnType */
        $this->assertSame('mixed', $returnType->getName());
    }
}
