<?php

declare(strict_types=1);

namespace App\Tests\Unit\Logging;

use App\Infrastructure\Logging\SecretScrubbingProcessor;
use Monolog\Formatter\JsonFormatter;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\TestCase;

final class SecretScrubbingProcessorTest extends TestCase
{
    public function testBearerTokenInContextIsScrubbedAndPreservesKindPrefix(): void
    {
        $processor = new SecretScrubbingProcessor();
        $record = $this->record('outbound api call', [
            'authorization' => 'Bearer irdb_svc_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
        ]);

        $out = $processor($record);

        // Key-based redaction wins; the whole value is replaced with ***.
        self::assertSame('***', $out->context['authorization']);
    }

    public function testBareTokenInMessageIsScrubbed(): void
    {
        $processor = new SecretScrubbingProcessor();
        $record = $this->record(
            'attempted with token irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
            []
        );

        $out = $processor($record);

        self::assertStringContainsString('irdb_adm_***', $out->message);
        self::assertStringNotContainsString('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', $out->message);
    }

    public function testFormattedOutputDoesNotLeakBearerToken(): void
    {
        // Round-trip the record through the processor and the JsonFormatter
        // to confirm the *rendered* log line is clean — what actually hits
        // stdout in production.
        $processor = new SecretScrubbingProcessor();
        $record = $this->record('api request', [
            'headers' => ['Authorization' => 'Bearer irdb_rep_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'],
        ]);

        $out = $processor($record);
        $formatter = new JsonFormatter();
        $line = $formatter->format($out);

        self::assertStringNotContainsString('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $line);
        self::assertStringContainsString('***', $line);
    }

    public function testPasswordHashKeyIsScrubbed(): void
    {
        $processor = new SecretScrubbingProcessor();
        $record = $this->record('config dump', [
            'LOCAL_ADMIN_PASSWORD_HASH' => '$argon2id$v=19$m=65536,t=4,p=1$abcdef$ghijkl',
            'OIDC_CLIENT_SECRET' => 'super-secret-value',
            'MAXMIND_LICENSE_KEY' => 'license-12345',
            'IPINFO_TOKEN' => 'token-67890',
            'DB_MYSQL_PASSWORD' => 'rootpass',
        ]);

        $out = $processor($record);

        foreach (['LOCAL_ADMIN_PASSWORD_HASH', 'OIDC_CLIENT_SECRET', 'MAXMIND_LICENSE_KEY', 'IPINFO_TOKEN', 'DB_MYSQL_PASSWORD'] as $key) {
            self::assertSame('***', $out->context[$key], "key {$key} not scrubbed");
        }
    }

    public function testArgon2HashEmbeddedInMessageIsScrubbed(): void
    {
        $processor = new SecretScrubbingProcessor();
        $record = $this->record(
            'verifying $argon2id$v=19$m=65536,t=4,p=1$abc$def for user',
            []
        );

        $out = $processor($record);

        self::assertStringNotContainsString('$argon2id$v=19', $out->message);
        self::assertStringContainsString('$argon2***', $out->message);
    }

    public function testNonSensitiveContentIsLeftAlone(): void
    {
        $processor = new SecretScrubbingProcessor();
        $record = $this->record('user search hit', [
            'count' => 42,
            'email' => 'someone@example.com',
            'ip' => '203.0.113.42',
        ]);

        $out = $processor($record);

        self::assertSame(42, $out->context['count']);
        self::assertSame('someone@example.com', $out->context['email']);
        self::assertSame('203.0.113.42', $out->context['ip']);
        self::assertSame('user search hit', $out->message);
    }

    public function testRawJwtInValueIsScrubbed(): void
    {
        // SEC_REVIEW F65: a JWT logged under any key (not in the
        // sensitive-key list) must still be scrubbed by the value
        // pattern. JWTs always start `eyJ` (base64url of `{"`), so the
        // pattern is anchored on that.
        $processor = new SecretScrubbingProcessor();
        $jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTYifQ.qj8u_Mt1ZyT5PfksI91X4-3aBwQ-_Pwm';
        $record = $this->record('upstream returned id_token', [
            'jwt' => $jwt,
        ]);

        $out = $processor($record);

        self::assertSame('eyJ***', $out->context['jwt']);
    }

    public function testRawJwtInMessageIsScrubbed(): void
    {
        $processor = new SecretScrubbingProcessor();
        $jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3In0.q-MT_OmW-fakesigvaluehere';
        $record = $this->record("decoded payload from {$jwt} ok", []);

        $out = $processor($record);

        self::assertStringContainsString('eyJ***', $out->message);
        self::assertStringNotContainsString($jwt, $out->message);
    }

    public function testShortBearerTokenIsScrubbed(): void
    {
        // SEC_REVIEW F65: the previous floor of {20,} let a short
        // Bearer slip through. The new floor is {8,}.
        $processor = new SecretScrubbingProcessor();
        $record = $this->record('outbound', [
            'auth' => 'Bearer abc12345',
        ]);

        $out = $processor($record);

        // 'auth' isn't in the key-name list, so we exercise the value
        // pattern. The Bearer prefix stays as a triage breadcrumb.
        self::assertSame('Bearer ***', $out->context['auth']);
    }

    public function testIpAddressDoesNotMatchJwtRegex(): void
    {
        // False-positive guard: dotted-quad IP looks like
        // `int.int.int` but doesn't start with `eyJ`, so it's
        // outside the JWT pattern.
        $processor = new SecretScrubbingProcessor();
        $record = $this->record('handling 192.168.1.1 request', [
            'ip' => '203.0.113.42',
        ]);

        $out = $processor($record);

        self::assertSame('203.0.113.42', $out->context['ip']);
        self::assertStringContainsString('192.168.1.1', $out->message);
    }

    public function testNestedContextIsScrubbedRecursively(): void
    {
        $processor = new SecretScrubbingProcessor();
        $record = $this->record('nested', [
            'request' => [
                'method' => 'POST',
                'authorization' => 'Bearer irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
                'body' => ['ok' => true],
            ],
        ]);

        $out = $processor($record);

        self::assertSame('***', $out->context['request']['authorization']);
        self::assertSame('POST', $out->context['request']['method']);
        self::assertTrue($out->context['request']['body']['ok']);
    }

    /**
     * @param array<string, mixed> $context
     */
    private function record(string $message, array $context): LogRecord
    {
        return new LogRecord(
            datetime: new \DateTimeImmutable(),
            channel: 'test',
            level: Level::Info,
            message: $message,
            context: $context,
        );
    }
}
