<?php

declare(strict_types=1);

namespace App\Tests\Integration\Public;

use App\Domain\Auth\Role;
use App\Domain\Auth\TokenKind;
use App\Tests\Integration\Support\AppTestCase;

final class ReportControllerTest extends AppTestCase
{
    public function testValidReportInsertedAndScoreUpdated(): void
    {
        $reporterId = $this->createReporter('web-prod');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);

        $resp = $this->request(
            'POST',
            '/api/v1/report',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            json_encode(['ip' => '203.0.113.42', 'category' => 'brute_force']) ?: null,
        );
        self::assertSame(202, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertArrayHasKey('report_id', $body);
        self::assertSame('203.0.113.42', $body['ip']);
        self::assertArrayHasKey('received_at', $body);

        // ip_scores must have a row > 0.
        $score = $this->db->fetchOne(
            "SELECT score FROM ip_scores WHERE ip_text = '203.0.113.42'"
        );
        self::assertNotFalse($score);
        self::assertGreaterThan(0.0, (float) $score);
    }

    public function testManyReportsAccumulateMonotonically(): void
    {
        $reporterId = $this->createReporter('web-many');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);

        for ($i = 0; $i < 5; $i++) {
            $this->request(
                'POST',
                '/api/v1/report',
                ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
                json_encode(['ip' => '198.51.100.7', 'category' => 'scanner']) ?: null,
            );
        }

        $count = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM reports WHERE ip_text = '198.51.100.7'"
        );
        self::assertSame(5, $count);

        $score = (float) $this->db->fetchOne(
            "SELECT score FROM ip_scores WHERE ip_text = '198.51.100.7'"
        );
        // 5 fresh reports × weight 1.0 × decay~1.0 should be ~5.0.
        self::assertEqualsWithDelta(5.0, $score, 0.05);
    }

    public function testWrongKindTokenRejected(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);

        $resp = $this->request(
            'POST',
            '/api/v1/report',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            json_encode(['ip' => '1.2.3.4', 'category' => 'spam']) ?: null,
        );
        self::assertSame(401, $resp->getStatusCode());
    }

    public function testInvalidIpReturns400(): void
    {
        $reporterId = $this->createReporter('web-bad-ip');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);

        $resp = $this->request(
            'POST',
            '/api/v1/report',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            json_encode(['ip' => 'not-an-ip', 'category' => 'spam']) ?: null,
        );
        self::assertSame(400, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertSame('validation_failed', $body['error']);
        self::assertArrayHasKey('ip', $body['details']);
    }

    public function testUnknownCategoryReturns400(): void
    {
        $reporterId = $this->createReporter('web-bad-cat');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);

        $resp = $this->request(
            'POST',
            '/api/v1/report',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            json_encode(['ip' => '1.2.3.4', 'category' => 'no-such']) ?: null,
        );
        self::assertSame(400, $resp->getStatusCode());
        self::assertArrayHasKey('category', $this->decode($resp)['details']);
    }

    public function testMetadataMustBeObject(): void
    {
        $reporterId = $this->createReporter('web-bad-meta');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);

        $resp = $this->request(
            'POST',
            '/api/v1/report',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            json_encode(['ip' => '1.2.3.4', 'category' => 'spam', 'metadata' => [1, 2, 3]]) ?: null,
        );
        self::assertSame(400, $resp->getStatusCode());
    }

    public function testMetadataExceedingLimitRejected(): void
    {
        $reporterId = $this->createReporter('web-big-meta');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);

        $big = ['blob' => str_repeat('A', 5000)];
        $resp = $this->request(
            'POST',
            '/api/v1/report',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            json_encode(['ip' => '1.2.3.4', 'category' => 'spam', 'metadata' => $big]) ?: null,
        );
        self::assertSame(400, $resp->getStatusCode());
    }

    public function testInactiveReporterTokenRejected(): void
    {
        $reporterId = $this->createReporter('web-disabled');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
        $this->db->update('reporters', ['is_active' => 0], ['id' => $reporterId]);

        $resp = $this->request(
            'POST',
            '/api/v1/report',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            json_encode(['ip' => '1.2.3.4', 'category' => 'spam']) ?: null,
        );
        self::assertSame(401, $resp->getStatusCode());
    }

    public function testOversizedRequestBodyIsRejectedWith413(): void
    {
        // SEC_REVIEW F69: a Reporter token can no longer POST a
        // multi-megabyte body that gets `getContents()`-ed into
        // memory by Slim's BodyParsingMiddleware. The global
        // RequestBodySizeLimitMiddleware (256 KiB cap) returns 413
        // BEFORE the body is read.
        $reporterId = $this->createReporter('web-oversize');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);

        // 512 KiB of plaintext, well past the 256 KiB cap.
        $bigBody = json_encode([
            'ip' => '203.0.113.42',
            'category' => 'spam',
            'metadata' => ['blob' => str_repeat('A', 512 * 1024)],
        ]) ?: '';

        $resp = $this->request(
            'POST',
            '/api/v1/report',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            $bigBody,
        );

        self::assertSame(413, $resp->getStatusCode());
        self::assertSame('payload_too_large', $this->decode($resp)['error']);
    }

    public function testDeeplyNestedJsonDoesNotBlowTheStack(): void
    {
        // SEC_REVIEW F69: bounded `json_decode` depth (32). A
        // 100-level nested object would otherwise hit PHP's default
        // 512-deep recursion limit and risk a stack issue on top of
        // wasting CPU. Past the cap, json_decode throws —
        // `ReportController::jsonBody` catches and treats as empty,
        // so the request continues and fails the regular `ip
        // required` validation.
        $reporterId = $this->createReporter('web-deepjson');
        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);

        // Build a 100-deep nested object.
        $payload = '{}';
        for ($i = 0; $i < 100; $i++) {
            $payload = '{"x":' . $payload . '}';
        }

        $resp = $this->request(
            'POST',
            '/api/v1/report',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            $payload,
        );

        // Slim's BodyParsingMiddleware uses its own json_decode
        // (depth 512), so it succeeds — but then the controller's
        // validation runs and rejects "ip required" with 400. The
        // important guarantee is "no 500, no exception leaked"; the
        // exact response code isn't the point.
        self::assertContains($resp->getStatusCode(), [400, 413]);
    }
}
