<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Enums\ActorType;
use App\Enums\AuthEventType;
use App\Models\AuditLog;
use App\Models\AuthEvent;
use App\Models\User;
use App\Services\Audit\AuditRecorder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\Test;
use RuntimeException;
use Tests\TestCase;

/**
 * Section 41: the audit trail is what settles a dispute months later, so its
 * integrity properties are worth testing directly.
 */
final class AuditTrailTest extends TestCase
{
    use RefreshDatabase;

    private function recorder(): AuditRecorder
    {
        return app(AuditRecorder::class);
    }

    #[Test]
    public function it_records_an_event_against_a_subject(): void
    {
        $user = User::factory()->create();

        $log = $this->recorder()->record(
            event: 'listing.price_changed',
            subject: $user,
            before: ['price_minor' => 100_000],
            after: ['price_minor' => 90_000],
            actor: $user,
        );

        $this->assertSame('listing.price_changed', $log->event);
        $this->assertSame($user->getKey(), $log->actor_id);
        $this->assertSame(100_000, $log->before['price_minor']);
        $this->assertSame(90_000, $log->after['price_minor']);
    }

    /**
     * The whole point of the trail: it must not be rewritable, or it proves
     * nothing when someone disputes what happened.
     */
    #[Test]
    public function an_audit_entry_cannot_be_updated(): void
    {
        $log = $this->recorder()->recordSystem('user.registered');

        $this->expectException(RuntimeException::class);

        $log->update(['event' => 'something.else']);
    }

    #[Test]
    public function an_audit_entry_cannot_be_deleted(): void
    {
        $log = $this->recorder()->recordSystem('user.registered');

        $this->expectException(RuntimeException::class);

        $log->delete();
    }

    /**
     * Recording the trail must not itself become the leak.
     */
    #[Test]
    public function it_redacts_secrets_from_snapshots(): void
    {
        $log = $this->recorder()->record(
            event: 'user.password_changed',
            before: ['password' => 'the-old-password'],
            after: ['password' => 'the-new-password'],
            context: [
                'two_factor_secret' => 'ABCDEF',
                'auth_code' => 'epp-transfer-code',
                'tax_id' => 'TAX-1234',
                'nested' => ['api_key' => 'sk_live_secret'],
                'safe_field' => 'kept',
            ],
        );

        $this->assertSame('[redacted]', $log->before['password']);
        $this->assertSame('[redacted]', $log->after['password']);
        $this->assertSame('[redacted]', $log->context['two_factor_secret']);
        $this->assertSame('[redacted]', $log->context['auth_code']);
        $this->assertSame('[redacted]', $log->context['tax_id']);
        $this->assertSame('[redacted]', $log->context['nested']['api_key']);
        $this->assertSame('kept', $log->context['safe_field']);
    }

    #[Test]
    public function it_diffs_only_the_changed_attributes(): void
    {
        $user = User::factory()->create(['name' => 'Asha Rai', 'locale' => 'en']);

        $user->name = 'Asha B. Rai';

        $log = $this->recorder()->recordChanges('user.profile_updated', $user);

        $this->assertNotNull($log);
        $this->assertSame(['name' => 'Asha Rai'], $log->before);
        $this->assertSame(['name' => 'Asha B. Rai'], $log->after);
        // An untouched attribute must not appear at all.
        $this->assertArrayNotHasKey('locale', $log->after);
    }

    #[Test]
    public function recording_changes_on_an_unchanged_model_writes_nothing(): void
    {
        $user = User::factory()->create();

        $this->assertNull($this->recorder()->recordChanges('user.profile_updated', $user));
        $this->assertDatabaseCount('audit_logs', 0);
    }

    #[Test]
    public function a_system_event_is_attributed_to_the_system(): void
    {
        $log = $this->recorder()->recordSystem('offer.expired');

        $this->assertSame(ActorType::System, $log->actor_type);
        $this->assertNull($log->actor_id);
    }

    #[Test]
    public function a_staff_action_is_attributed_to_an_admin(): void
    {
        $admin = User::factory()->admin()->create();

        $log = $this->recorder()->record('user.suspended', actor: $admin);

        $this->assertSame(ActorType::Admin, $log->actor_type);
    }

    /**
     * One user action fans out across jobs. Sharing a correlation id is what
     * lets the whole sequence be reassembled during an investigation.
     */
    #[Test]
    public function events_in_one_request_share_a_correlation_id(): void
    {
        $user = User::factory()->create();

        $this->actingAs($user)->patchJson('/api/v1/account', ['city' => 'Kathmandu'])->assertOk();

        $correlationIds = AuditLog::query()->pluck('correlation_id')->unique();

        $this->assertCount(1, $correlationIds);
        $this->assertNotNull($correlationIds->first());
    }

    #[Test]
    public function the_request_id_is_returned_to_the_caller(): void
    {
        $this->getJson('/api/v1/plans')
            ->assertOk()
            ->assertHeader('X-Request-Id');
    }

    /**
     * An inbound request id is only honoured if it is well formed, so a caller
     * cannot inject arbitrary text into audit records.
     */
    #[Test]
    public function a_malformed_inbound_request_id_is_replaced(): void
    {
        $response = $this->withHeader('X-Request-Id', 'not-a-uuid; DROP TABLE audit_logs')
            ->getJson('/api/v1/plans')
            ->assertOk();

        $this->assertNotSame(
            'not-a-uuid; DROP TABLE audit_logs',
            $response->headers->get('X-Request-Id')
        );
    }

    #[Test]
    public function a_well_formed_inbound_request_id_is_preserved(): void
    {
        $id = (string) Str::uuid();

        $this->withHeader('X-Request-Id', $id)
            ->getJson('/api/v1/plans')
            ->assertOk()
            ->assertHeader('X-Request-Id', $id);
    }

    #[Test]
    public function an_auth_event_cannot_be_updated(): void
    {
        $event = AuthEvent::create([
            'event' => AuthEventType::LoginSuccess,
            'email_attempted' => 'asha@example.com',
        ]);

        $this->expectException(RuntimeException::class);

        $event->update(['event' => AuthEventType::Logout]);
    }
}
