<?php

namespace MintyPHP\Tests\Support\Helpers;

use MintyPHP\App\AppContainer;
use MintyPHP\Router;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Support\Guard;
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

/**
 * Helper-contract tests for core/Support/helpers/action_context.php.
 *
 * Step 1 of the action-context-helper rollout. The helpers have no production
 * call-sites yet; this suite documents the contract.
 */
class ActionContextHelperTest extends TestCase
{
    use AppContainerIsolationTrait;

    /** @var bool */
    private bool $previousExecuteRedirect = true;

    /** @var array<string, mixed> */
    private array $serverBackup = [];

    /** @var array<string, mixed> */
    private array $sessionBackup = [];

    protected function setUp(): void
    {
        parent::setUp();
        $this->previousExecuteRedirect = Router::$executeRedirect;
        Router::$executeRedirect = false;
        $this->resetRouterState();

        $this->serverBackup = $_SERVER;
        $this->sessionBackup = $_SESSION ?? [];
        $_SERVER['REQUEST_URI'] = '/test';
        $_SERVER['REQUEST_METHOD'] = 'GET';
        // Setting tenant_context_refreshed_at to "now" prevents Guard::requireLogin()
        // from calling AuthService::loadTenantDataIntoSession (60s throttle window).
        $_SESSION = [
            'user' => ['id' => 1],
            'tenant_context_refreshed_at' => time(),
        ];

        Guard::configure(
            authServiceResolver: static fn () => throw new \RuntimeException('AuthService not stubbed in this test'),
            tenantServiceResolver: static fn () => throw new \RuntimeException('TenantService not stubbed in this test'),
            authorizationServiceResolver: static fn (): AuthorizationService => self::makeAuthorizationServiceStub(static fn () => AuthorizationDecision::allow()),
        );
    }

    protected function tearDown(): void
    {
        Router::$executeRedirect = $this->previousExecuteRedirect;
        $this->resetRouterState();
        $_SERVER = $this->serverBackup;
        $_SESSION = $this->sessionBackup;
        $this->restoreAppContainer();
        parent::tearDown();
    }

    /* ----------------------------------------------------------------
     * actionResolveModelOrFail
     * ---------------------------------------------------------------- */

    public function testResolveModelOrFailReturnsModelOnHit(): void
    {
        $finder = static fn (string $id): array => ['id' => $id, 'name' => 'demo'];

        $model = actionResolveModelOrFail($finder, 'abc', 'action.context.model_not_found', 'admin/users');

        $this->assertSame(['id' => 'abc', 'name' => 'demo'], $model);
    }

    public function testResolveModelOrFailRedirectsOnMiss(): void
    {
        $finder = static fn (): mixed => null;

        $result = actionResolveModelOrFail($finder, 'missing', 'action.context.model_not_found', 'admin/users');

        $this->assertNull($result);
        $this->assertStringEndsWith('admin/users', $this->capturedRedirect());
    }

    /* ----------------------------------------------------------------
     * actionAuthorizeAndExtractCapabilities
     * ---------------------------------------------------------------- */

    public function testAuthorizeAndExtractCapabilitiesReturnsAttributesOnAllow(): void
    {
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
            'capabilities' => ['can_view_page' => true, 'can_edit' => true],
        ]));

        $caps = actionAuthorizeAndExtractCapabilities('ABILITY_X', ['actor_user_id' => 1]);

        $this->assertSame(['can_view_page' => true, 'can_edit' => true], $caps);
    }

    public function testAuthorizeAndExtractCapabilitiesRedirectsOnDeny(): void
    {
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::deny());

        $caps = actionAuthorizeAndExtractCapabilities('ABILITY_X', [], 'redirect');

        $this->assertSame([], $caps);
        $this->assertStringContainsString('error/forbidden', $this->capturedRedirect());
    }

    public function testAuthorizeAndExtractCapabilitiesUsesGuardDenyStrategy(): void
    {
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::deny());

        $caps = actionAuthorizeAndExtractCapabilities('ABILITY_X', [], 'deny');

        // Guard::deny() ultimately also calls Router::redirect('error/forbidden?url=...')
        // when Request::wantsJson() is false. With executeRedirect=false this is captured.
        $this->assertSame([], $caps);
        $this->assertStringContainsString('error/forbidden', $this->capturedRedirect());
    }

    public function testAuthorizeAndExtractCapabilitiesNormalizesNonArrayAttribute(): void
    {
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
            'capabilities' => 'not-an-array',
        ]));

        $caps = actionAuthorizeAndExtractCapabilities('ABILITY_X', []);

        $this->assertSame([], $caps);
    }

    /* ----------------------------------------------------------------
     * actionDeriveTenantScope (PHPStan array-shape)
     * ---------------------------------------------------------------- */

    public function testTenantScopeReturnsAllOnlyWhenFlagIsLiteralTrue(): void
    {
        $this->assertSame(
            ['scope' => 'all', 'ids' => []],
            actionDeriveTenantScope(['can_manage_all_tenants' => true])
        );
    }

    public function testTenantScopeWithoutFlagNeverReturnsAll(): void
    {
        // Even if allowed_tenant_ids is missing, scope must NOT widen to 'all'.
        $this->assertSame(
            ['scope' => 'list', 'ids' => []],
            actionDeriveTenantScope([])
        );
        // Truthy-but-not-true must NOT widen to 'all'.
        foreach ([1, '1', 'yes', [true]] as $truthy) {
            /** @var array<string, mixed> $caps */
            $caps = ['can_manage_all_tenants' => $truthy];
            $this->assertSame(
                'list',
                actionDeriveTenantScope($caps)['scope'],
                'Truthy non-true flag must not widen scope: ' . var_export($truthy, true)
            );
        }
    }

    public function testTenantScopeBuildsListFromAllowedIds(): void
    {
        $result = actionDeriveTenantScope([
            'allowed_tenant_ids' => [1, '2', 3, 0, -5, '4', 1],
        ]);
        $this->assertSame(['scope' => 'list', 'ids' => [1, 2, 3, 4]], $result);
    }

    public function testTenantScopeNonArrayListReturnsEmpty(): void
    {
        $result = actionDeriveTenantScope(['allowed_tenant_ids' => 'whatever']);
        $this->assertSame(['scope' => 'list', 'ids' => []], $result);
    }

    /* ----------------------------------------------------------------
     * actionEnforceCanViewPage (strict ===true)
     * ---------------------------------------------------------------- */

    public function testEnforceCanViewPageAcceptsLiteralTrue(): void
    {
        actionEnforceCanViewPage(['can_view_page' => true]);
        $this->assertSame('', $this->capturedRedirect());
    }

    public function testEnforceCanViewPageRejectsTruthyValues(): void
    {
        foreach ([1, '1', 'yes', [true], 'true', 0.0, null] as $bad) {
            $this->resetRouterState();
            /** @var array<string, mixed> $caps */
            $caps = ['can_view_page' => $bad];
            actionEnforceCanViewPage($caps);
            $this->assertStringContainsString(
                'error/forbidden',
                $this->capturedRedirect(),
                'Should have blocked truthy value: ' . var_export($bad, true)
            );
        }
    }

    public function testEnforceCanViewPageRejectsMissingFlag(): void
    {
        actionEnforceCanViewPage([]);
        $this->assertStringContainsString('error/forbidden', $this->capturedRedirect());
    }

    /* ----------------------------------------------------------------
     * actionBuildViewAuth
     * ---------------------------------------------------------------- */

    public function testBuildViewAuthLimitsToWhitelist(): void
    {
        $caps = ['can_view_page' => true, 'can_edit' => true, 'secret_flag' => 'leaked'];
        $result = actionBuildViewAuth($caps, ['can_view_page', 'can_edit']);

        $this->assertSame(
            ['page' => ['can_view_page' => true, 'can_edit' => true]],
            $result
        );
        $this->assertArrayNotHasKey('secret_flag', $result['page']);
    }

    public function testBuildViewAuthIgnoresUnknownFlags(): void
    {
        $result = actionBuildViewAuth(['can_view_page' => true], ['can_view_page', 'nonexistent']);
        $this->assertSame(['page' => ['can_view_page' => true]], $result);
    }

    /* ----------------------------------------------------------------
     * Aggregator: actionEditContext
     * ---------------------------------------------------------------- */

    public function testEditContextHappyPathReturnsModelAndScope(): void
    {
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
            'capabilities' => [
                'can_view_page' => true,
                'can_edit' => true,
                'allowed_tenant_ids' => [1, 2],
            ],
        ]));

        $result = actionEditContext([
            'finder' => static fn (string $id): array => ['id' => $id],
            'rawId' => 'u-1',
            'notFoundFlashKey' => 'action.context.model_not_found',
            'notFoundRedirectPath' => 'admin/users',
            'abilityKey' => 'ABILITY_USERS_EDIT_CONTEXT',
            'context' => ['actor_user_id' => 1],
            'viewAuthFlags' => ['can_view_page', 'can_edit'],
        ]);

        $this->assertSame(['id' => 'u-1'], $result['model']);
        $this->assertSame('list', $result['tenantScope']['scope']);
        $this->assertSame([1, 2], $result['tenantScope']['ids']);
        $this->assertArrayHasKey('can_view_page', $result['viewAuth']['page']);
        $this->assertArrayNotHasKey('allowed_tenant_ids', $result['viewAuth']['page']);
    }

    public function testEditContextMissingModelRedirects(): void
    {
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
            'capabilities' => ['can_view_page' => true],
        ]));

        actionEditContext([
            'finder' => static fn (): mixed => null,
            'rawId' => 'missing',
            'notFoundFlashKey' => 'action.context.model_not_found',
            'notFoundRedirectPath' => 'admin/users',
            'abilityKey' => 'ABILITY_USERS_EDIT_CONTEXT',
            'context' => [],
            'viewAuthFlags' => [],
        ]);

        $this->assertStringContainsString('admin/users', $this->capturedRedirect());
    }

    public function testEditContextWithFlashScopeKeyOverride(): void
    {
        // Departments-Edit pilot use-case: aggregator must emit a flash with
        // the caller-specific scope-key ('department_not_found') instead of
        // the building-block default ('not_found') when notFoundFlashScopeKey
        // is provided. Required for Step 2 behavior identity (Run
        // 2026-04-25-action-context-rollout-step2-departments).
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
            'capabilities' => ['can_view_page' => true],
        ]));
        $_SESSION['flash_messages'] = [];

        actionEditContext([
            'finder' => static fn (): mixed => null,
            'rawId' => 'missing',
            'notFoundFlashKey' => 'Department not found',
            'notFoundRedirectPath' => 'admin/departments',
            'notFoundFlashScopeKey' => 'department_not_found',
            'abilityKey' => 'ABILITY_DEPARTMENTS_EDIT_CONTEXT',
            'context' => [],
            'viewAuthFlags' => [],
        ]);

        $this->assertStringContainsString('admin/departments', $this->capturedRedirect());

        $entry = $this->findFlashEntryByKey('department_not_found');
        $this->assertNotNull($entry, "Expected flash with key 'department_not_found' to be set by aggregator override.");
        $this->assertSame('error', $entry['type'] ?? null);
        $this->assertSame('admin/departments', $entry['scope'] ?? null);
    }

    public function testEditContextWithoutFlashScopeKeyKeepsBuildingBlockDefault(): void
    {
        // Without notFoundFlashScopeKey, the aggregator delegates to
        // actionResolveModelOrFail which hardcodes scope-key 'not_found'.
        // This test pins the default-path behavior so a future addition
        // doesn't accidentally widen building-block semantics.
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
            'capabilities' => ['can_view_page' => true],
        ]));
        $_SESSION['flash_messages'] = [];

        actionEditContext([
            'finder' => static fn (): mixed => null,
            'rawId' => 'missing',
            'notFoundFlashKey' => 'action.context.model_not_found',
            'notFoundRedirectPath' => 'admin/users',
            'abilityKey' => 'ABILITY_USERS_EDIT_CONTEXT',
            'context' => [],
            'viewAuthFlags' => [],
        ]);

        $entry = $this->findFlashEntryByKey('not_found');
        $this->assertNotNull($entry, "Expected default building-block scope-key 'not_found' when notFoundFlashScopeKey is absent.");
    }

    /**
     * @return array<string, mixed>|null
     */
    private function findFlashEntryByKey(string $key): ?array
    {
        /** @var mixed $rawMessages */
        $rawMessages = $_SESSION['flash_messages'] ?? [];
        if (!is_array($rawMessages)) {
            return null;
        }
        foreach ($rawMessages as $entry) {
            if (!is_array($entry)) {
                continue;
            }
            if (($entry['key'] ?? null) === $key) {
                return $entry;
            }
        }
        return null;
    }

    /* ----------------------------------------------------------------
     * Aggregator: actionCreateContext
     * ---------------------------------------------------------------- */

    public function testCreateContextHappyPath(): void
    {
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
            'capabilities' => [
                'can_view_page' => true,
                'can_manage_all_tenants' => true,
            ],
        ]));

        $result = actionCreateContext([
            'abilityKey' => 'ABILITY_USERS_CREATE',
            'context' => [],
            'viewAuthFlags' => ['can_view_page'],
        ]);

        $this->assertSame('all', $result['tenantScope']['scope']);
        $this->assertSame([], $result['tenantScope']['ids']);
        $this->assertSame(['page' => ['can_view_page' => true]], $result['viewAuth']);
    }

    public function testCreateContextDeniedRedirects(): void
    {
        $this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::deny());

        actionCreateContext([
            'abilityKey' => 'ABILITY_USERS_CREATE',
            'context' => [],
            'viewAuthFlags' => [],
        ]);

        $this->assertStringContainsString('error/forbidden', $this->capturedRedirect());
    }

    /* ----------------------------------------------------------------
     * helpers
     * ---------------------------------------------------------------- */

    /**
     * Replace the AuthorizationService in the AppContainer with a stub that
     * returns the decision produced by $factory on every authorize() call.
     *
     * @param callable(): AuthorizationDecision $factory
     */
    private function stubAuthorizationService(callable $factory): void
    {
        $service = self::makeAuthorizationServiceStub($factory);

        $container = new AppContainer();
        $container->set(AuthorizationService::class, static fn (): AuthorizationService => $service);
        $this->pushAppContainer($container);

        // Guard::deny() is unused in most tests; if a forbidden-strategy=deny
        // test runs, Guard's authorizationServiceResolver also resolves to the
        // same stub via the closure captured in setUp().
        Guard::configure(
            authServiceResolver: static fn () => throw new \RuntimeException('AuthService not stubbed'),
            tenantServiceResolver: static fn () => throw new \RuntimeException('TenantService not stubbed'),
            authorizationServiceResolver: static fn (): AuthorizationService => $service,
        );
    }

    /**
     * @param callable(): AuthorizationDecision $factory
     */
    private static function makeAuthorizationServiceStub(callable $factory): AuthorizationService
    {
        return new class ($factory) extends AuthorizationService {
            /** @var callable */
            private $factory;

            public function __construct(callable $factory)
            {
                parent::__construct([]);
                $this->factory = $factory;
            }

            public function authorize(string $ability, array $context = []): AuthorizationDecision
            {
                return ($this->factory)();
            }
        };
    }

    private function capturedRedirect(): string
    {
        $reflection = new ReflectionClass(Router::class);
        $prop = $reflection->getProperty('redirect');
        $value = $prop->getValue();
        return is_string($value) ? $value : '';
    }

    private function resetRouterState(): void
    {
        $reflection = new ReflectionClass(Router::class);
        foreach (['redirect', 'initialized'] as $name) {
            if (!$reflection->hasProperty($name)) {
                continue;
            }
            $prop = $reflection->getProperty($name);
            if ($name === 'initialized') {
                $prop->setValue(null, true); // skip initialize() (which reads $_SERVER routing state)
            } else {
                $prop->setValue(null, null);
            }
        }
    }
}
