<?php

declare(strict_types=1);

namespace Apexl\Tests\User\Helpers;

use apexl\Config\Configuration;
use apexl\Config\ConfigurationInterface;
use apexl\Io\modules\user\enums\TwoFactorMode;
use apexl\Io\modules\user\services\twoFactorAuth\TwoFactorAuthService;
use PHPUnit\Framework\Assert;
use ReflectionProperty;

use function apexl\Io\container;

/**
 * Test helper: override `auth.two_factor.*` config at runtime. The config file is
 * loaded + locked at bootstrap, so this reaches into the shared Configuration's
 * flat map via reflection and restores it afterwards. Call
 * restoreTwoFactorConfig() in tearDown().
 *
 * Two deliberate properties, both learned the hard way:
 *
 * 1. Only the keys actually overridden are captured and restored — not the whole
 *    flat map. Configuration::$flatData is *derived*: setFlatArray() rebuilds it
 *    wholesale from the nested $data on every load(), and get() reads only the
 *    flat copy. Snapshotting the entire map and writing it back therefore either
 *    resurrected keys a later load() had legitimately changed, or dropped ones it
 *    had added, depending on what ran in between. Touching one key at a time
 *    cannot do either.
 *
 * 2. Every override is verified to have taken effect before the test proceeds. An
 *    override that silently fails to apply is the worst case: the test goes on to
 *    exercise whatever mode the config file happens to hold, and the mismatch
 *    surfaces as a baffling assertion failure somewhere downstream — or, if a
 *    value outlives its restore, in a *different test entirely*. That is how an
 *    `enforced` + zero-grace pair once leaked into the off-mode and optional-mode
 *    tests in TwoFactorHttpFlowTest, which had only reordered ahead of them
 *    because phpunit.xml sets executionOrder="depends,defects" and an earlier run
 *    had failed. Failing loudly, here, attributes it correctly.
 */
trait ManagesTwoFactorConfig
{
    /**
     * Original state of every key this test overrode, as [existed, value] so a key
     * that was absent can be removed again rather than restored as a null.
     *
     * @var array<string, array{0: bool, 1: mixed}>
     */
    private array $originalTwoFactorConfig = [];

    /**
     * The mode in force before this test touched anything, read through the service
     * the gate consults. Captured so restoreTwoFactorConfig() can prove it put it
     * back rather than assume it.
     */
    private ?TwoFactorMode $twoFactorModeBeforeOverride = null;

    protected function setTwoFactorMode(string $mode): void
    {
        $this->setTwoFactorConfig(['auth.two_factor.mode' => $mode]);
    }

    /**
     * @param array<string, mixed> $overrides flat keys, e.g. 'auth.two_factor.mode'
     */
    protected function setTwoFactorConfig(array $overrides): void
    {
        $config = container()->get(ConfigurationInterface::class);
        if (!$config instanceof Configuration) {
            return;
        }

        // Before anything is written, and only once per test: what restore has to
        // land back on.
        $this->twoFactorModeBeforeOverride ??= container()->get(TwoFactorAuthService::class)->mode();

        $property = new ReflectionProperty(Configuration::class, 'flatData');
        $flat = $property->getValue($config);

        foreach ($overrides as $key => $value) {
            // First touch only: a test may set the same key twice, and a second
            // capture would record the first override as the "original".
            if (!array_key_exists($key, $this->originalTwoFactorConfig)) {
                $this->originalTwoFactorConfig[$key] = [
                    array_key_exists($key, $flat),
                    $flat[$key] ?? null,
                ];
            }

            $flat[$key] = $value;
        }

        $property->setValue($config, $flat);

        $this->assertTwoFactorConfigApplied($overrides);
    }

    protected function restoreTwoFactorConfig(): void
    {
        if ($this->originalTwoFactorConfig === []) {
            return;
        }

        $config = container()->get(ConfigurationInterface::class);
        if ($config instanceof Configuration) {
            $property = new ReflectionProperty(Configuration::class, 'flatData');
            $flat = $property->getValue($config);

            foreach ($this->originalTwoFactorConfig as $key => [$existed, $value]) {
                if ($existed) {
                    $flat[$key] = $value;
                } else {
                    unset($flat[$key]);
                }
            }

            $property->setValue($config, $flat);
        }

        $this->originalTwoFactorConfig = [];

        $this->assertTwoFactorConfigRestored();
    }

    /**
     * Prove the mode is back where it started, through the same service the gate
     * reads — the setter already refuses to proceed on an override that did not
     * apply, and this is the other half of that bargain.
     *
     * Without it a failed restore is silent here and surfaces as an assertion
     * failure in whatever test runs next, which is not the test with the bug. The
     * pair that leaks is `enforced` + zero grace: every later login response then
     * carries two_factor_required, and the off-mode and optional-mode tests in
     * TwoFactorHttpFlowTest fail for reasons they have no part in. They are also
     * the tests most likely to be *reordered* onto the end of that leak, since
     * phpunit.xml sets executionOrder="depends,defects" — so the pairing that
     * fails is not stable between runs and the report reads as flakiness.
     *
     * Asserted in tearDown, so the failure is attributed to the test that left the
     * config dirty.
     */
    private function assertTwoFactorConfigRestored(): void
    {
        $expected = $this->twoFactorModeBeforeOverride;
        $this->twoFactorModeBeforeOverride = null;

        if ($expected === null) {
            return;
        }

        Assert::assertSame(
            $expected,
            container()->get(TwoFactorAuthService::class)->mode(),
            'This test left the two-factor mode overridden. Every test that runs after it '
            . 'now evaluates the gate against the wrong mode, and the failure will be '
            . 'reported against one of them instead of here.'
        );
    }

    /**
     * @param array<string, mixed> $overrides
     */
    private function assertTwoFactorConfigApplied(array $overrides): void
    {
        $config = container()->get(ConfigurationInterface::class);

        foreach ($overrides as $key => $value) {
            Assert::assertSame(
                $value,
                $config->get($key),
                sprintf(
                    'Config override "%s" did not take effect. Configuration::$flatData is rebuilt '
                    . 'from the nested $data by any load(), so an override can be silently discarded.',
                    $key
                )
            );
        }

        // Read the mode back through the service the gate and the middleware
        // actually consult, rather than trusting the raw config: that is the path
        // under test, and it resolves anything unrecognised to OFF instead of
        // erroring, so a typo'd mode would otherwise pass unnoticed.
        if (array_key_exists('auth.two_factor.mode', $overrides)) {
            Assert::assertSame(
                TwoFactorMode::fromConfigValue($overrides['auth.two_factor.mode']),
                container()->get(TwoFactorAuthService::class)->mode(),
                'The two-factor mode override did not reach TwoFactorAuthService, so the gate '
                . 'under test would evaluate against the wrong mode.'
            );
        }
    }
}
