<?php

declare(strict_types=1);

namespace App\Tests\Integration\Public;

use App\Domain\Auth\Role;
use App\Domain\Auth\TokenKind;
use App\Domain\Ip\Cidr;
use App\Domain\Ip\IpAddress;
use App\Tests\Integration\Support\AppTestCase;
use Doctrine\DBAL\ParameterType;

/**
 * Covers SPEC §M07.3: distribution endpoint.
 *  - kind=consumer required (admin token forbidden)
 *  - text/plain default; ?format=json
 *  - allowlist excludes; manual blocks emit
 *  - ETag round-trip → 304
 *  - response headers populated
 */
final class BlocklistControllerTest extends AppTestCase
{
    public function testAdminTokenIsRejected(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
        $response = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(401, $response->getStatusCode());
    }

    public function testConsumerTokenReturnsEmptyTextByDefault(): void
    {
        $token = $this->setupConsumerToken('moderate');

        $response = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(200, $response->getStatusCode());
        self::assertSame('text/plain', $response->getHeaderLine('Content-Type'));
        self::assertSame('', (string) $response->getBody());
        self::assertSame('0', $response->getHeaderLine('X-Blocklist-Entries'));
        self::assertSame('moderate', $response->getHeaderLine('X-Blocklist-Policy'));
        self::assertNotSame('', $response->getHeaderLine('ETag'));
        self::assertNotSame('', $response->getHeaderLine('X-Blocklist-Generated-At'));
    }

    public function testManualBlockAppearsInTextBlocklist(): void
    {
        $token = $this->setupConsumerToken('moderate');
        $this->insertManualSubnet('198.51.100.0/24');

        $response = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(200, $response->getStatusCode());
        self::assertStringContainsString('198.51.100.0/24', (string) $response->getBody());
        self::assertSame('1', $response->getHeaderLine('X-Blocklist-Entries'));
    }

    public function testJsonFormatReturnsStructuredEntries(): void
    {
        $token = $this->setupConsumerToken('moderate');
        $this->insertManualIp('203.0.113.7');

        $response = $this->request('GET', '/api/v1/blocklist?format=json', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(200, $response->getStatusCode());
        self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
        $body = json_decode((string) $response->getBody(), true);
        self::assertIsArray($body);
        self::assertCount(1, $body);
        self::assertSame('203.0.113.7', $body[0]['ip_or_cidr']);
        self::assertSame('manual', $body[0]['reason']);
        self::assertNull($body[0]['score']);
    }

    public function testEtagRoundTripReturns304(): void
    {
        $token = $this->setupConsumerToken('moderate');
        $this->insertManualSubnet('198.51.100.0/24');

        $first = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        $etag = $first->getHeaderLine('ETag');
        self::assertNotSame('', $etag);

        $second = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $token,
            'If-None-Match' => $etag,
        ]);
        self::assertSame(304, $second->getStatusCode());
        self::assertSame('', (string) $second->getBody());
    }

    public function testEtagDiffersBetweenTextAndJson(): void
    {
        $token = $this->setupConsumerToken('moderate');
        $this->insertManualSubnet('198.51.100.0/24');

        $text = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        $json = $this->request('GET', '/api/v1/blocklist?format=json', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertNotSame($text->getHeaderLine('ETag'), $json->getHeaderLine('ETag'));
    }

    public function testAllowlistedManualSubnetIsExcluded(): void
    {
        $token = $this->setupConsumerToken('moderate');
        $this->insertManualSubnet('198.51.100.0/24');
        // Allowlist a subnet that fully contains the manual block.
        $this->insertAllowSubnet('198.51.100.0/16');

        $response = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(200, $response->getStatusCode());
        self::assertStringNotContainsString('198.51.100.0/24', (string) $response->getBody());
    }

    /**
     * Three policies with distinct thresholds yield distinct entry counts
     * over the same scored data — SPEC §M07 acceptance.
     */
    public function testThreePoliciesProduceDifferentBlocklists(): void
    {
        $strict = $this->setupConsumerToken('strict', 'consumer-strict');
        $moderate = $this->setupConsumerToken('moderate', 'consumer-moderate');
        $paranoid = $this->setupConsumerToken('paranoid', 'consumer-paranoid');

        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
        // Score chosen to fall between paranoid (0.3) and moderate (1.0).
        $this->insertScore('203.0.113.10', $bruteForceId, 0.5);
        // Score that paranoid + moderate catch but strict (2.5) misses.
        $this->insertScore('203.0.113.20', $bruteForceId, 1.5);
        // Score that all three policies catch.
        $this->insertScore('203.0.113.30', $bruteForceId, 3.0);

        $strictResp = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $strict,
        ]);
        $moderateResp = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $moderate,
        ]);
        $paranoidResp = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $paranoid,
        ]);

        self::assertSame('1', $strictResp->getHeaderLine('X-Blocklist-Entries'));
        self::assertSame('2', $moderateResp->getHeaderLine('X-Blocklist-Entries'));
        self::assertSame('3', $paranoidResp->getHeaderLine('X-Blocklist-Entries'));
    }

    private function setupConsumerToken(string $policyName, string $consumerName = 'fw-test'): string
    {
        $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => $policyName]);
        $this->db->insert('consumers', [
            'name' => $consumerName,
            'policy_id' => $policyId,
            'is_active' => 1,
        ]);
        $consumerId = (int) $this->db->lastInsertId();

        return $this->createToken(TokenKind::Consumer, consumerId: $consumerId);
    }

    private function insertManualIp(string $ip): void
    {
        $bin = IpAddress::fromString($ip)->binary();
        $stmt = $this->db->prepare(
            'INSERT INTO manual_blocks (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)'
        );
        $stmt->bindValue('kind', 'ip');
        $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT);
        $stmt->bindValue('reason', 'test');
        $stmt->executeStatement();
    }

    private function insertManualSubnet(string $cidr): void
    {
        $c = Cidr::fromString($cidr);
        $stmt = $this->db->prepare(
            'INSERT INTO manual_blocks (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
        );
        $stmt->bindValue('kind', 'subnet');
        $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
        $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
        $stmt->bindValue('reason', 'test');
        $stmt->executeStatement();
    }

    private function insertAllowSubnet(string $cidr): void
    {
        $c = Cidr::fromString($cidr);
        $stmt = $this->db->prepare(
            'INSERT INTO allowlist (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
        );
        $stmt->bindValue('kind', 'subnet');
        $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
        $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
        $stmt->bindValue('reason', 'test');
        $stmt->executeStatement();
    }

    private function insertScore(string $ip, int $categoryId, float $score): void
    {
        $ipObj = IpAddress::fromString($ip);
        $stmt = $this->db->prepare(
            'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
            . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
        );
        $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
        $stmt->bindValue('t', $ipObj->text());
        $stmt->bindValue('c', $categoryId, ParameterType::INTEGER);
        $stmt->bindValue('s', number_format($score, 4, '.', ''));
        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
        $stmt->executeStatement();
    }

    public function testBlocklistRenderIsCachedAcrossRequests(): void
    {
        // SEC_REVIEW F70: the rendered body and its sha256 ETag are
        // cached per (policy_id, format) per cache window. Two
        // sequential requests within the same window should produce
        // byte-identical bodies and ETags. (Cache hit → no rebuild,
        // no rehash; the test asserts the steady-state contract.)
        $token = $this->setupConsumerToken('moderate');

        $r1 = $this->request('GET', '/api/v1/blocklist?format=json', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        $r2 = $this->request('GET', '/api/v1/blocklist?format=json', [
            'Authorization' => 'Bearer ' . $token,
        ]);

        self::assertSame(200, $r1->getStatusCode());
        self::assertSame(200, $r2->getStatusCode());
        self::assertSame((string) $r1->getBody(), (string) $r2->getBody());
        self::assertSame($r1->getHeaderLine('ETag'), $r2->getHeaderLine('ETag'));
    }

    public function testCacheIsLruBoundedAtSixteenEntries(): void
    {
        // SEC_REVIEW F71: BlocklistCache is per-process and grew
        // monotonically with no LRU. With unbounded admin policy
        // creation, memory usage was effectively unbounded over the
        // worker's lifetime. Cache now caps at MAX_ENTRIES (16) and
        // evicts the least-recently-used entry when full.
        //
        // The default test fixture sets `blocklist_cache_ttl_seconds=0`
        // (no caching) for deterministic per-test builds. Rebuild
        // the cache with a real TTL so we exercise the LRU path.
        if (method_exists($this->container, 'set')) {
            /** @var \DI\Container $c */
            $c = $this->container;
            $c->set(
                \App\Infrastructure\Reputation\BlocklistCache::class,
                new \App\Infrastructure\Reputation\BlocklistCache(
                    builder: $c->get(\App\Domain\Reputation\BlocklistBuilder::class),
                    clock: $c->get(\App\Domain\Time\Clock::class),
                    ttlSeconds: 30,
                ),
            );
            // BlocklistController is autowired and singleton-scoped, so
            // it captured the OLD cache reference at first resolution.
            // Rebuild it so it picks up the new cache binding.
            $c->set(
                \App\Application\Public\BlocklistController::class,
                $c->make(\App\Application\Public\BlocklistController::class),
            );
            $this->app = \App\App\AppFactory::build($this->container);
        }
        /** @var \App\Infrastructure\Reputation\BlocklistCache $cache */
        $cache = $this->container->get(\App\Infrastructure\Reputation\BlocklistCache::class);

        // Create 18 policies and a consumer-token per policy. Hit
        // /blocklist for each so each policy ends up in the cache.
        // The LRU should evict the first 2 by the time we reach the
        // last (cache cap is 16).
        $policyIds = [];
        for ($i = 1; $i <= 18; $i++) {
            $this->db->insert('policies', [
                'name' => 'lru-policy-' . $i,
                'description' => null,
                'include_manual_blocks' => 1,
            ]);
            $policyId = (int) $this->db->lastInsertId();
            $policyIds[] = $policyId;
            $this->db->insert('consumers', [
                'name' => 'lru-consumer-' . $i,
                'policy_id' => $policyId,
                'is_active' => 1,
            ]);
            $consumerId = (int) $this->db->lastInsertId();
            $token = $this->createToken(TokenKind::Consumer, consumerId: $consumerId);

            $resp = $this->request('GET', '/api/v1/blocklist', [
                'Authorization' => 'Bearer ' . $token,
            ]);
            self::assertSame(200, $resp->getStatusCode(), "consumer {$i} should succeed");
        }

        // Cache must never exceed the MAX_ENTRIES bound.
        self::assertLessThanOrEqual(
            \App\Infrastructure\Reputation\BlocklistCache::MAX_ENTRIES,
            $cache->entryCount(),
            'cache must not grow past MAX_ENTRIES policies',
        );
        // The two oldest policies (first two created) must have been
        // evicted; the most recently used must still be present.
        $remaining = $cache->cachedPolicyIds();
        self::assertNotContains($policyIds[0], $remaining, 'oldest policy must have been evicted');
        self::assertNotContains($policyIds[1], $remaining, 'second-oldest policy must have been evicted');
        self::assertContains(end($policyIds), $remaining, 'most recent policy must still be cached');
    }

    public function testTextAndJsonRendersBothCachedIndependently(): void
    {
        // SEC_REVIEW F70: switching format must not invalidate the
        // other format's cached render. Each format has its own
        // entry in the cache.
        $token = $this->setupConsumerToken('moderate');

        $textA = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        $jsonA = $this->request('GET', '/api/v1/blocklist?format=json', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        $textB = $this->request('GET', '/api/v1/blocklist', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        $jsonB = $this->request('GET', '/api/v1/blocklist?format=json', [
            'Authorization' => 'Bearer ' . $token,
        ]);

        self::assertSame((string) $textA->getBody(), (string) $textB->getBody());
        self::assertSame($textA->getHeaderLine('ETag'), $textB->getHeaderLine('ETag'));
        self::assertSame((string) $jsonA->getBody(), (string) $jsonB->getBody());
        self::assertSame($jsonA->getHeaderLine('ETag'), $jsonB->getHeaderLine('ETag'));
        // Text and JSON ETags must DIFFER (different bodies).
        self::assertNotSame($textA->getHeaderLine('ETag'), $jsonA->getHeaderLine('ETag'));
    }
}
