<?php declare(strict_types=1);

/*
 * This file is part of the Monolog package.
 *
 * (c) Jordi Boggiano <j.boggiano@seld.be>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Monolog\Formatter;

use Monolog\Level;
use Monolog\LogRecord;
use Monolog\Processor\PsrLogMessageProcessor;
use Monolog\Test\MonologTestCase;
use PHPUnit\Framework\Attributes\DataProvider;

/**
 * @covers Monolog\Formatter\RedactingFormatter
 */
class RedactingFormatterTest extends MonologTestCase
{
    public function testRedactsSensitiveKeysInContextAndExtra()
    {
        $captured = null;
        $formatter = new RedactingFormatter($this->capturingFormatter($captured));

        $formatter->format($this->getRecord(
            context: ['user' => 'rishi', 'password' => 'super-secret', 'nested' => ['api_key' => 'abc123']],
            extra: ['token' => 't-456', 'irrelevant' => 'keep'],
        ));

        $this->assertSame('rishi', $captured->context['user']);
        $this->assertSame('[REDACTED]', $captured->context['password']);
        $this->assertSame('[REDACTED]', $captured->context['nested']['api_key']);
        $this->assertSame('[REDACTED]', $captured->extra['token']);
        $this->assertSame('keep', $captured->extra['irrelevant']);
    }

    public function testKeyMatchingIsCaseInsensitive()
    {
        $captured = null;
        $formatter = new RedactingFormatter($this->capturingFormatter($captured));

        $formatter->format($this->getRecord(context: ['Password' => 'x', 'AUTHORIZATION' => 'Bearer y']));

        $this->assertSame('[REDACTED]', $captured->context['Password']);
        $this->assertSame('[REDACTED]', $captured->context['AUTHORIZATION']);
    }

    public function testRedactsPatternsInFinalOutput()
    {
        $formatter = new RedactingFormatter(
            new LineFormatter('%message% %context%', 'Y-m-d'),
            sensitiveKeys: [],
            patterns: ['{Bearer\s+[A-Za-z0-9._-]+}'],
        );

        $output = $formatter->format($this->getRecord(
            message: 'Auth header Bearer abc.def-ghi',
            context: ['header' => 'Bearer zzz999'],
        ));

        $this->assertStringContainsString('Auth header [REDACTED]', $output);
        $this->assertStringNotContainsString('Bearer', $output);
    }

    public function testTokenPatternConstantRedactsLongTokens()
    {
        $formatter = new RedactingFormatter(
            new LineFormatter('%message%', 'Y-m-d'),
            sensitiveKeys: [],
            patterns: [RedactingFormatter::TOKEN_PATTERN],
        );

        $output = $formatter->format($this->getRecord(message: 'token is test_0123456789abcdefghijklmnopqrstuvwxyz here'));

        $this->assertStringContainsString('token is [REDACTED] here', $output);
    }

    public function testSweepsSecretsHiddenInsideNormalizedObjects()
    {
        // The secret lives inside an exception that only becomes a string once
        // the wrapped JsonFormatter normalizes the record. Redacting the record
        // alone would miss it; running the patterns over the output catches it.
        $formatter = new RedactingFormatter(
            new JsonFormatter(),
            sensitiveKeys: [],
            patterns: ['{Bearer\s+[A-Za-z0-9._-]+}'],
        );

        $output = $formatter->format($this->getRecord(
            context: ['exception' => new \RuntimeException('failed using Bearer abc.def-ghi')],
        ));

        $this->assertIsString($output);
        $this->assertStringNotContainsString('Bearer abc.def-ghi', $output);
        $this->assertStringContainsString('[REDACTED]', $output);
    }

    public function testSweepsArrayReturningFormatters()
    {
        $formatter = new RedactingFormatter(
            new NormalizerFormatter('Y-m-d'),
            sensitiveKeys: [],
            patterns: ['{Bearer\s+[A-Za-z0-9._-]+}'],
        );

        $output = $formatter->format($this->getRecord(message: 'Bearer abc.def-ghi'));

        $this->assertIsArray($output);
        $this->assertSame('[REDACTED]', $output['message']);
    }

    public function testFormatBatchRedactsEachRecord()
    {
        $formatter = new RedactingFormatter(
            new LineFormatter('%message% %context%', 'Y-m-d'),
            patterns: ['{Bearer\s+[A-Za-z0-9._-]+}'],
        );

        // LineFormatter::formatBatch joins records into a single string
        $output = $formatter->formatBatch([
            $this->getRecord(message: 'one Bearer aaa.bbb-ccc', context: ['password' => 'p1']),
            $this->getRecord(message: 'two', context: ['token' => 't2']),
        ]);

        $this->assertIsString($output);
        $this->assertStringNotContainsString('Bearer', $output);
        $this->assertStringNotContainsString('p1', $output);
        $this->assertStringNotContainsString('t2', $output);
        $this->assertSame(3, substr_count($output, '[REDACTED]')); // password key, token key, and Bearer pattern
    }

    public function testFormatBatchWithArrayReturningFormatter()
    {
        $formatter = new RedactingFormatter(new NormalizerFormatter('Y-m-d'));

        $output = $formatter->formatBatch([
            $this->getRecord(context: ['password' => 'p1']),
            $this->getRecord(context: ['token' => 't2']),
        ]);

        $this->assertIsArray($output);
        $this->assertSame('[REDACTED]', $output[0]['context']['password']);
        $this->assertSame('[REDACTED]', $output[1]['context']['token']);
    }

    public function testThrowsOnInvalidPattern()
    {
        $this->expectException(\InvalidArgumentException::class);

        new RedactingFormatter(new LineFormatter(), patterns: ['{invalid']);
    }

    public function testNoPatternsLeavesOutputUntouched()
    {
        $inner = new LineFormatter('%message%', 'Y-m-d');
        $record = $this->getRecord(message: 'a long token test_0123456789abcdefghijklmnopqrstuvwxyz');

        $formatter = new RedactingFormatter($inner, sensitiveKeys: []);

        $this->assertSame($inner->format($record), $formatter->format($record));
    }

    public function testCustomMask()
    {
        $captured = null;
        $formatter = new RedactingFormatter($this->capturingFormatter($captured), mask: '***');

        $formatter->format($this->getRecord(context: ['password' => 'x']));

        $this->assertSame('***', $captured->context['password']);
    }

    public function testRedactsSensitiveParameterValues()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(context: ['user' => new SensitiveParamHolder()]));

        $this->assertStringNotContainsString('s3cret-webhook', $output);
        $this->assertStringContainsString('[REDACTED]', $output);
        // the non-sensitive parameter is left alone
        $this->assertStringContainsString('user-1', $output);
    }

    public function testRedactsSensitiveParameterValuesWithJsonFormatter()
    {
        // JsonFormatter hands objects straight to json_encode, so nothing normalizes them
        $formatter = new RedactingFormatter(new JsonFormatter());

        $output = $formatter->format($this->getRecord(context: ['user' => new SensitiveParamHolder()]));

        $this->assertIsString($output);
        $this->assertStringNotContainsString('s3cret-webhook', $output);
        $this->assertStringContainsString('"mySecretUrl":"[REDACTED]"', $output);
    }

    public function testRedactsNonPublicSensitiveParameterLeakedByToString()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(context: ['conn' => new SensitiveDsnHolder()]));

        $this->assertStringNotContainsString('hunter2', $output);
        $this->assertStringContainsString('Connection([REDACTED])', $output);
    }

    public function testRedactsNonPromotedSensitiveParameterMatchedByPropertyName()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(context: ['sub' => new SensitiveNonPromotedHolder()]));

        $this->assertStringNotContainsString('tok-abcdefghij', $output);
        $this->assertStringContainsString('[REDACTED]', $output);
    }

    public function testRedactsSensitiveParameterOnNestedObjectAndInsideArrays()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(context: ['list' => [['nested' => new SensitiveNestingHolder()]]]));

        $this->assertStringNotContainsString('s3cret-webhook', $output);
        $this->assertStringContainsString('[REDACTED]', $output);
    }

    public function testRedactsSecretAlreadyInterpolatedIntoTheMessage()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d'));
        $processor = new PsrLogMessageProcessor();

        $output = $formatter->format($processor($this->getRecord(
            message: 'calling api with {token}',
            context: ['token' => 'tok-supersecret-value'],
        )));

        $this->assertStringNotContainsString('tok-supersecret-value', $output);
        $this->assertStringContainsString('calling api with [REDACTED]', $output);
    }

    public function testRedactsSecretEmbeddedInAnExceptionMessage()
    {
        $formatter = new RedactingFormatter(new JsonFormatter());

        $output = $formatter->format($this->getRecord(context: [
            'user' => new SensitiveParamHolder(),
            'exception' => new \RuntimeException('could not reach https://example.org/s3cret-webhook'),
        ]));

        $this->assertIsString($output);
        $this->assertStringNotContainsString('s3cret-webhook', $output);
        $this->assertStringContainsString('could not reach [REDACTED]', $output);
    }

    public function testRedactsSecretsNeedingJsonEscaping()
    {
        $formatter = new RedactingFormatter(new JsonFormatter(), sensitiveKeys: ['password']);

        $output = $formatter->format($this->getRecord(
            message: 'login failed for pa"ss\\word-123',
            context: ['password' => 'pa"ss\\word-123'],
        ));

        $this->assertIsString($output);
        $this->assertStringNotContainsString('word-123', $output);
        $this->assertSame(2, substr_count($output, '[REDACTED]')); // masked key + swept message
    }

    public function testRedactsSensitiveParameterValueObjects()
    {
        if (!class_exists(\SensitiveParameterValue::class)) {
            $this->markTestSkipped('SensitiveParameterValue requires PHP 8.2');
        }

        $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(
            message: 'token was tok-supersecret-value',
            context: ['arg' => new \SensitiveParameterValue('tok-supersecret-value')],
        ));

        $this->assertStringNotContainsString('tok-supersecret-value', $output);
    }

    public function testShortSecretsAreNotSwept()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(
            message: 'user pass logged in',
            context: ['password' => 'pass'],
        ));

        // the key is still masked, but "pass" is too short to be removed from the message
        $this->assertStringContainsString('user pass logged in', $output);
        $this->assertStringContainsString('[REDACTED]', $output);
    }

    public function testMinSecretLengthIsConfigurable()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%message%', 'Y-m-d'), minSecretLength: 4);

        $output = $formatter->format($this->getRecord(message: 'user pass logged in', context: ['password' => 'pass']));

        $this->assertStringContainsString('user [REDACTED] logged in', $output);
    }

    public function testSensitiveParameterSupportCanBeDisabled()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d'), redactSensitiveParameters: false);

        $output = $formatter->format($this->getRecord(
            message: 'calling https://example.org/s3cret-webhook',
            context: ['user' => new SensitiveParamHolder(), 'token' => 'tok-supersecret-value'],
        ));

        $this->assertStringContainsString('calling https://example.org/s3cret-webhook', $output);
        // sensitive keys are still swept from the output, only the objects are left alone
        $this->assertStringNotContainsString('tok-supersecret-value', $output);
    }

    #[DataProvider('provideEdgeCaseObjects')]
    public function testHandlesObjectsWithoutUsableConstructors(object $object)
    {
        $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(context: ['edge' => $object]));

        $this->assertIsString($output);
    }

    public static function provideEdgeCaseObjects(): array
    {
        $cyclic = new SensitiveCyclicHolder();
        $cyclic->next = $cyclic;

        return [
            'no constructor' => [new \stdClass()],
            'enum' => [SensitiveTestEnum::One],
            'anonymous class' => [new class ('anon-secret-value') {
                public function __construct(#[\SensitiveParameter] public string $secret)
                {
                }
            }],
            'uninitialized property' => [(new \ReflectionClass(SensitiveNonPromotedHolder::class))->newInstanceWithoutConstructor()],
            'cyclic graph' => [$cyclic],
        ];
    }

    public function testCyclicGraphSecretsAreStillFound()
    {
        $cyclic = new SensitiveCyclicHolder();
        $cyclic->next = $cyclic;

        $formatter = new RedactingFormatter(new LineFormatter('%message%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(message: 'leaked cyclic-secret-value', context: ['obj' => $cyclic]));

        $this->assertStringContainsString('leaked [REDACTED]', $output);
    }

    public function testRedactsSensitiveParameterInheritedFromAParentClass()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(context: ['creds' => new ChildCredentials()]));

        $this->assertStringNotContainsString('tok-inherited-secret', $output);
        $this->assertStringContainsString('token=[REDACTED]', $output);
    }

    public function testRedactsStringableSecretsAtSensitiveKeys()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(
            message: 'authenticating with stringable-secret-value',
            context: ['token' => new StringableSecret()],
        ));

        $this->assertStringNotContainsString('stringable-secret-value', $output);
    }

    public function testRedactsStringableSensitiveParameters()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(context: ['holder' => new StringableSecretHolder()]));

        $this->assertStringNotContainsString('stringable-secret-value', $output);
        $this->assertStringContainsString('auth=[REDACTED]', $output);
    }

    public function testAThrowingToStringDoesNotBreakFormatting()
    {
        $formatter = new RedactingFormatter(new LineFormatter('%message% %context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(
            message: 'still logged',
            context: ['token' => new ThrowingStringable()],
        ));

        $this->assertStringContainsString('still logged', $output);
    }

    public function testARecordIsDroppedRatherThanLeakedWhenAPatternCannotBeApplied()
    {
        // a /u pattern over invalid UTF-8 makes preg_replace() return null
        $formatter = new RedactingFormatter(
            new LineFormatter('%message%', 'Y-m-d'),
            sensitiveKeys: [],
            patterns: ['{tok-[a-z0-9]+}u'],
        );

        $output = $formatter->format($this->getRecord(message: 'secret tok-abc123 in '.\chr(0xFF).' here'));

        $this->assertStringNotContainsString('tok-abc123', $output);
        $this->assertStringContainsString('RedactingFormatter dropped this record', $output);
        // neither the pattern nor the mask is echoed back, both may embed secret material
        $this->assertStringNotContainsString('tok-[a-z0-9]+', $output);
    }

    public function testSelfReferencingContextArraysDoNotRecurseForever()
    {
        $context = ['password' => 'super-secret-value'];
        $context['self'] = &$context;

        $formatter = new RedactingFormatter(new LineFormatter('%context%', 'Y-m-d'));

        $output = $formatter->format($this->getRecord(context: $context));

        $this->assertIsString($output);
        $this->assertStringNotContainsString('super-secret-value', $output);
    }

    public function testSelfReferencingFormatterOutputDoesNotRecurseForever()
    {
        $inner = new class implements FormatterInterface {
            public function format(LogRecord $record)
            {
                $formatted = ['leaked' => 'tok-supersecret-value'];
                $formatted['self'] = &$formatted;

                return $formatted;
            }

            public function formatBatch(array $records)
            {
                return array_map(fn (LogRecord $record) => $this->format($record), $records);
            }
        };

        $output = (new RedactingFormatter($inner))->format($this->getRecord(context: ['token' => 'tok-supersecret-value']));

        $this->assertIsArray($output);
        $this->assertSame('[REDACTED]', $output['leaked']);
    }

    public function testGetWrappedFormatter()
    {
        $inner = new LineFormatter();

        $this->assertSame($inner, (new RedactingFormatter($inner))->getWrappedFormatter());
    }

    /**
     * Returns a formatter that records the (already redacted) LogRecord it receives
     * into $captured, so tests can assert on the structured record passed downstream.
     */
    private function capturingFormatter(?LogRecord &$captured): FormatterInterface
    {
        return new class($captured) implements FormatterInterface {
            public function __construct(private ?LogRecord &$captured)
            {
            }

            public function format(LogRecord $record)
            {
                $this->captured = $record;

                return '';
            }

            public function formatBatch(array $records)
            {
                foreach ($records as $record) {
                    $this->captured = $record;
                }

                return '';
            }
        };
    }
}

class SensitiveParamHolder
{
    public function __construct(
        public string $id = 'user-1',
        #[\SensitiveParameter]
        public string $mySecretUrl = 'https://example.org/s3cret-webhook',
    ) {
    }
}

class SensitiveDsnHolder
{
    public function __construct(
        #[\SensitiveParameter]
        private string $dsn = 'mysql://user:hunter2@localhost/db',
    ) {
    }

    public function __toString(): string
    {
        return 'Connection('.$this->dsn.')';
    }
}

class SensitiveNonPromotedHolder
{
    public string $token;

    public function __construct(#[\SensitiveParameter] string $token = 'tok-abcdefghij')
    {
        $this->token = $token;
    }
}

class SensitiveNestingHolder
{
    public function __construct(public SensitiveParamHolder $inner = new SensitiveParamHolder())
    {
    }
}

class SensitiveCyclicHolder
{
    public ?self $next = null;

    public function __construct(#[\SensitiveParameter] public string $secret = 'cyclic-secret-value')
    {
    }
}

enum SensitiveTestEnum: string
{
    case One = 'one';
}

class ParentCredentials
{
    public function __construct(
        #[\SensitiveParameter]
        private string $token = 'tok-inherited-secret',
    ) {
    }

    public function __toString(): string
    {
        return "token={$this->token}";
    }
}

class ChildCredentials extends ParentCredentials
{
}

class StringableSecret
{
    public function __construct(private string $value = 'stringable-secret-value')
    {
    }

    public function __toString(): string
    {
        return $this->value;
    }
}

class StringableSecretHolder
{
    public function __construct(
        #[\SensitiveParameter]
        private StringableSecret $token = new StringableSecret(),
    ) {
    }

    public function __toString(): string
    {
        return 'auth='.$this->token;
    }
}

class ThrowingStringable
{
    public function __toString(): string
    {
        throw new \RuntimeException('nope');
    }
}
