<?php

declare(strict_types=1);

namespace App\Tests\Unit\Logging;

use App\Infrastructure\Logging\SafeTrace;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Throwable;

/**
 * Regression test for SEC_REVIEW F21: PHP's getTraceAsString() inlines
 * scalar args into each frame, which leaks plaintext secrets when an
 * exception is thrown from inside `password_verify`, an OIDC token
 * validation call, etc. SafeTrace must drop args entirely.
 */
final class SafeTraceTest extends TestCase
{
    public function testFormattedTraceDoesNotContainArgs(): void
    {
        $secret = 'super-plaintext-password-please-do-not-leak';
        $caught = $this->capture(fn () => $this->throwWithSecret($secret));

        $trace = SafeTrace::format($caught);

        self::assertStringNotContainsString($secret, $trace);
        self::assertStringContainsString('throwWithSecret', $trace);
    }

    public function testFormattedTraceWalksPreviousChain(): void
    {
        $innerSecret = 'inner-secret-123';
        $outerSecret = 'outer-secret-456';

        $caught = $this->capture(function () use ($innerSecret, $outerSecret): void {
            try {
                $this->throwWithSecret($innerSecret);
            } catch (Throwable $inner) {
                $this->wrap($outerSecret, $inner);
            }
        });

        $trace = SafeTrace::format($caught);
        self::assertStringNotContainsString($innerSecret, $trace);
        self::assertStringNotContainsString($outerSecret, $trace);
        self::assertStringContainsString('Caused by', $trace);
    }

    public function testFrameLayoutResemblesPhpNativeFormat(): void
    {
        $caught = $this->capture(fn () => $this->throwWithSecret('x'));

        $trace = SafeTrace::format($caught);

        // Numbered frames, file(line) suffix, class::method() call, then {main}.
        self::assertMatchesRegularExpression('/^#0 .+\(\d+\): .+\(\)$/m', $trace);
        self::assertStringEndsWith('{main}', $trace);
    }

    private function capture(callable $fn): Throwable
    {
        try {
            $fn();
        } catch (Throwable $e) {
            return $e;
        }
        self::fail('expected callable to throw');
    }

    private function throwWithSecret(string $secret): never
    {
        throw new RuntimeException('boom');
    }

    private function wrap(string $secret, Throwable $inner): never
    {
        throw new RuntimeException('outer', 0, $inner);
    }
}
