<?php

declare(strict_types=1);

namespace App\Tests\Integration\Auth;

use App\Auth\LoginThrottle;
use App\Http\CsrfMiddleware;
use App\Tests\Integration\Support\AppTestCase;

/**
 * Drive the local-admin login flow against the real Slim app + a mocked
 * api-side `upsertLocal` response. Exercises CSRF, throttle, redirect,
 * session-set, and api-down handling.
 */
final class LocalLoginTest extends AppTestCase
{
    protected function setUp(): void
    {
        $this->bootApp();
    }

    public function testGetLoginRendersForm(): void
    {
        $response = $this->request('GET', '/login');

        self::assertSame(200, $response->getStatusCode());
        $body = (string) $response->getBody();
        self::assertStringContainsString('Sign in', $body);
        // Local sign-in toggle present (oidc disabled in this fixture).
        self::assertStringContainsString('name="username"', $body);
        self::assertStringContainsString('csrf_token', $body);
    }

    public function testCorrectCredentialsLogInAndRedirectToMe(): void
    {
        $this->enqueueApiResponse(200, [
            'user_id' => 1,
            'role' => 'admin',
            'email' => null,
            'display_name' => 'Local Admin',
            'is_local' => true,
        ]);

        // Need a session + csrf token; first GET /login to set one up.
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
        self::assertNotEmpty($token);

        $body = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']);
        $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/app/dashboard', $response->getHeaderLine('Location'));
        self::assertNotNull($_SESSION['_user'] ?? null);
        self::assertSame('admin', $_SESSION['_user']['role']);
    }

    public function testWrongPasswordRedirectsBackToLoginWithFlash(): void
    {
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $body = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']);
        $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/login', $response->getHeaderLine('Location'));
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertSame('error', $flash[0]['type']);
    }

    public function testWrongUsernameAlsoRecordsFailure(): void
    {
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $body = http_build_query(['csrf_token' => $token, 'username' => 'someone', 'password' => 'test1234']);
        $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');

        // Failure recorded on the LoginThrottle (not in the session).
        // Five more attempts from the same IP for the same bogus user
        // would lock the bucket; one shot doesn't, so we just verify
        // the next attempt isn't locked yet.
        /** @var LoginThrottle $throttle */
        $throttle = $this->container->get(LoginThrottle::class);
        self::assertFalse($throttle->isLocked('someone', ''));
    }

    public function testWrongUsernameTriggersPasswordVerify(): void
    {
        // SEC_REVIEW F7: a wrong username must still go through
        // password_verify against an Argon2id hash, so timing does not
        // distinguish "username matches" from "username does not match".
        // We assert a generous lower bound (10 ms) — well above the
        // microsecond cost of a path that skips password_verify, and
        // well below PHP's default PASSWORD_ARGON2ID cost (~50 ms+).
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $body = http_build_query([
            'csrf_token' => $token,
            'username' => 'definitely_not_the_admin',
            'password' => 'irrelevant',
        ]);

        $start = hrtime(true);
        $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
        $elapsedNs = hrtime(true) - $start;

        self::assertSame(303, $response->getStatusCode());
        self::assertGreaterThan(
            10_000_000,
            $elapsedNs,
            'wrong-username path took <10ms; password_verify likely skipped (F7 regression)',
        );
    }

    public function testUnconfiguredLocalPasswordHashStillRunsPasswordVerify(): void
    {
        // SEC_REVIEW F7 defence-in-depth: even when LOCAL_ADMIN_PASSWORD_HASH
        // is empty, the login path must run password_verify against the
        // dummy Argon2id hash so an attacker cannot probe whether a local
        // admin password is configured by timing.
        $this->bootApp(['local_admin_password_hash' => '']);

        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $body = http_build_query([
            'csrf_token' => $token,
            'username' => 'admin',
            'password' => 'whatever',
        ]);

        $start = hrtime(true);
        $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
        $elapsedNs = hrtime(true) - $start;

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/login', $response->getHeaderLine('Location'));
        self::assertGreaterThan(
            10_000_000,
            $elapsedNs,
            'unconfigured-hash path took <10ms; dummy password_verify likely skipped (F7 regression)',
        );
    }

    public function testDisabledLocalAdminRecordsThrottleFailure(): void
    {
        // SEC_REVIEW F38: hitting POST /login/local when local-admin is
        // disabled returned 404 with no rate-limit. An attacker could
        // burn worker threads on this URL. The throttle must accumulate
        // a per-IP failure for each disabled-path hit so the existing
        // 5/10/15 ladder applies even on the disabled URL.
        $this->bootApp(['local_admin_enabled' => false]);
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $body = http_build_query([
            'csrf_token' => $token,
            'username' => 'whatever',
            'password' => 'spam',
        ]);
        $response = $this->request(
            'POST',
            '/login/local',
            [],
            $body,
            'application/x-www-form-urlencoded',
            ['REMOTE_ADDR' => '198.51.100.50'],
        );

        self::assertSame(404, $response->getStatusCode());
        // The per-IP bucket key is `('', source_ip)` — independent of the
        // attacker's submitted username so a rotating-username spray from
        // one IP all lands in the same bucket.
        /** @var LoginThrottle $throttle */
        $throttle = $this->container->get(LoginThrottle::class);
        self::assertFalse($throttle->isLocked('', '198.51.100.50'));
        // Five hits should trip the lockout regardless of submitted username.
        for ($i = 0; $i < 4; ++$i) {
            $varied = http_build_query([
                'csrf_token' => $token,
                'username' => 'rotating-' . $i,
                'password' => 'spam',
            ]);
            $this->request(
                'POST',
                '/login/local',
                [],
                $varied,
                'application/x-www-form-urlencoded',
                ['REMOTE_ADDR' => '198.51.100.50'],
            );
        }
        self::assertTrue($throttle->isLocked('', '198.51.100.50'));
    }

    public function testDisabledLocalAdminLockedHitDoesNotIncrementBucket(): void
    {
        // After lockout, additional hammering must not grow the bucket
        // unbounded — we want the throttle file size + decode cost to be
        // bounded by the lockout ladder, not by attacker request volume.
        $this->bootApp(['local_admin_enabled' => false]);
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $body = http_build_query([
            'csrf_token' => $token,
            'username' => 'x',
            'password' => 'x',
        ]);
        for ($i = 0; $i < 5; ++$i) {
            $this->request(
                'POST',
                '/login/local',
                [],
                $body,
                'application/x-www-form-urlencoded',
                ['REMOTE_ADDR' => '198.51.100.51'],
            );
        }
        /** @var LoginThrottle $throttle */
        $throttle = $this->container->get(LoginThrottle::class);
        self::assertTrue($throttle->isLocked('', '198.51.100.51'));
        $remainingBefore = $throttle->lockoutSecondsRemaining('', '198.51.100.51');

        // 50 more hits while locked — must not extend or escalate the lockout.
        for ($i = 0; $i < 50; ++$i) {
            $this->request(
                'POST',
                '/login/local',
                [],
                $body,
                'application/x-www-form-urlencoded',
                ['REMOTE_ADDR' => '198.51.100.51'],
            );
        }
        $remainingAfter = $throttle->lockoutSecondsRemaining('', '198.51.100.51');
        // Allow ±1s for clock drift across the calls.
        self::assertLessThanOrEqual($remainingBefore + 1, $remainingAfter);
    }

    public function testCsrfTokenIsRotatedAcrossLoginPrivilegeBoundary(): void
    {
        // SEC_REVIEW F40: a CSRF token minted on the pre-auth login form
        // must NOT carry over after a successful login. `regenerateId()`
        // now drops `_csrf` so the next protected GET mints a fresh
        // token. An attacker who scraped the pre-auth token (Referer,
        // sub-resource leak) cannot replay it post-auth.
        $this->enqueueApiResponse(200, [
            'user_id' => 1,
            'role' => 'admin',
            'email' => null,
            'display_name' => 'Local Admin',
            'is_local' => true,
        ]);

        $this->request('GET', '/login');
        $preAuthToken = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
        self::assertNotEmpty($preAuthToken);

        $body = http_build_query([
            'csrf_token' => $preAuthToken,
            'username' => 'admin',
            'password' => 'test1234',
        ]);
        $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');

        // After the redirect-to-dashboard the session has been
        // privilege-elevated; the slot is gone and the next safe
        // request through CsrfMiddleware mints a fresh token.
        self::assertArrayNotHasKey(CsrfMiddleware::SESSION_KEY, $_SESSION);
    }

    public function testCsrfMissingIs403(): void
    {
        $this->request('GET', '/login');
        $body = http_build_query(['username' => 'admin', 'password' => 'test1234']);

        $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(403, $response->getStatusCode());
    }

    public function testFiveFailuresLockOutNextAttempt(): void
    {
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']);
        for ($i = 0; $i < 5; ++$i) {
            $this->request('POST', '/login/local', [], $bad, 'application/x-www-form-urlencoded');
        }

        // 6th attempt — even with correct credentials — gets the lockout flash.
        $this->enqueueApiResponse(200, [
            'user_id' => 1, 'role' => 'admin', 'email' => null, 'display_name' => 'Local Admin', 'is_local' => true,
        ]);
        $good = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']);
        $response = $this->request('POST', '/login/local', [], $good, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/login', $response->getHeaderLine('Location'));
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertStringContainsStringIgnoringCase('too many', $flash[0]['message']);
    }

    public function testRotatingXForwardedForDoesNotEvadePerIpLockout(): void
    {
        // SEC_REVIEW F1: an attacker spoofing X-Forwarded-For per request
        // must NOT mint a fresh throttle bucket. The per-IP bucket is keyed
        // on REMOTE_ADDR, which is constant here — so 5 failures should
        // still trip the lockout regardless of XFF rotation.
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']);
        for ($i = 0; $i < 5; ++$i) {
            $this->request(
                'POST',
                '/login/local',
                ['X-Forwarded-For' => '203.0.113.' . $i],
                $bad,
                'application/x-www-form-urlencoded',
                ['REMOTE_ADDR' => '198.51.100.7'],
            );
        }

        // 6th attempt with correct credentials but a fresh XFF still gets the
        // lockout flash because the per-(user, REMOTE_ADDR) bucket is full.
        $this->enqueueApiResponse(200, [
            'user_id' => 1, 'role' => 'admin', 'email' => null, 'display_name' => 'Local Admin', 'is_local' => true,
        ]);
        $good = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']);
        $response = $this->request(
            'POST',
            '/login/local',
            ['X-Forwarded-For' => '203.0.113.99'],
            $good,
            'application/x-www-form-urlencoded',
            ['REMOTE_ADDR' => '198.51.100.7'],
        );

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/login', $response->getHeaderLine('Location'));
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertStringContainsStringIgnoringCase('too many', $flash[0]['message']);
    }

    public function testRotatingRemoteAddrEventuallyHitsPerUsernameLockout(): void
    {
        // SEC_REVIEW F2: even with proper REMOTE_ADDR, an attacker on a
        // residential proxy pool gets a fresh per-IP bucket per request.
        // The per-username (cross-IP) bucket must catch this at 25 failures.
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']);
        for ($i = 0; $i < 25; ++$i) {
            $this->request(
                'POST',
                '/login/local',
                [],
                $bad,
                'application/x-www-form-urlencoded',
                ['REMOTE_ADDR' => '198.51.100.' . $i],
            );
        }

        // Next attempt from yet another IP — even with correct credentials —
        // gets the lockout flash because the per-username bucket is full.
        $this->enqueueApiResponse(200, [
            'user_id' => 1, 'role' => 'admin', 'email' => null, 'display_name' => 'Local Admin', 'is_local' => true,
        ]);
        $good = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']);
        $response = $this->request(
            'POST',
            '/login/local',
            [],
            $good,
            'application/x-www-form-urlencoded',
            ['REMOTE_ADDR' => '198.51.100.250'],
        );

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/login', $response->getHeaderLine('Location'));
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertStringContainsStringIgnoringCase('too many', $flash[0]['message']);
    }

    public function testFailuresArePersistedToConfiguredFilePath(): void
    {
        // SEC_REVIEW F6: confirm production wiring writes throttle state
        // to the on-disk path (so a worker recycle cannot wipe it). Drives
        // a failure through the full Slim stack, then asserts the file
        // exists and decodes to the expected per-IP/per-username buckets.
        // The cross-instance persistence guarantee itself is covered in
        // FileThrottleStoreTest.
        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']);
        $this->request('POST', '/login/local', [], $bad, 'application/x-www-form-urlencoded');

        /** @var \DI\Container $c */
        $c = $this->container;
        $path = (string) $c->get('settings.login_throttle_path');
        self::assertFileExists($path);
        $decoded = json_decode((string) file_get_contents($path), true);
        self::assertIsArray($decoded);
        self::assertArrayHasKey('ip', $decoded);
        self::assertArrayHasKey('username', $decoded);
        self::assertArrayHasKey('admin', $decoded['username']);
        self::assertSame(1, $decoded['username']['admin']['count']);

        // And the live LoginThrottle (built via the container) sees that
        // count too — i.e., it shares the same store.
        /** @var LoginThrottle $live */
        $live = $c->get(LoginThrottle::class);
        self::assertFalse($live->isLocked('admin', '0.0.0.0'));
        // Top up to the per-IP threshold — uses the SAME file the prior
        // request wrote to.
        for ($i = 0; $i < 4; ++$i) {
            $live->recordFailure('admin', '');
        }
        self::assertTrue($live->isLocked('admin', ''));
    }

    public function testApiDownDuringUpsertFlashesError(): void
    {
        $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
            'connection refused',
            new \GuzzleHttp\Psr7\Request('POST', '/api/v1/auth/users/upsert-local'),
        ));
        $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
            'connection refused',
            new \GuzzleHttp\Psr7\Request('POST', '/api/v1/auth/users/upsert-local'),
        ));

        $this->request('GET', '/login');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $body = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']);
        $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/login', $response->getHeaderLine('Location'));
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertStringContainsStringIgnoringCase('api', $flash[0]['message']);
    }
}
