<?php

declare(strict_types=1);

namespace App\Tests\Integration\Reputation;

use App\Domain\Ip\Cidr;
use App\Domain\Ip\IpAddress;
use App\Domain\Reputation\BlocklistBuilder;
use App\Infrastructure\Policy\PolicyRepository;
use App\Tests\Integration\Support\AppTestCase;
use Doctrine\DBAL\ParameterType;

/**
 * Integration tests for the per-policy `BlocklistBuilder`.
 *  - allowlist filters scored entries
 *  - manual subnet covering a scored IP suppresses the single entry
 *  - sort: IPv4 before IPv6, stable across rebuilds
 *  - dedup: scored single + manual single → scored wins (carries categories)
 */
final class BlocklistBuilderTest extends AppTestCase
{
    public function testScoredIpAppearsInBlocklistWithCategorySlugs(): void
    {
        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
        $this->insertScore('203.0.113.5', $bruteForceId, 2.0);

        $blocklist = $this->buildFor('paranoid');

        self::assertCount(1, $blocklist->entries);
        $entry = $blocklist->entries[0];
        self::assertSame('203.0.113.5', $entry->ipOrCidr);
        self::assertSame('scored', $entry->reason);
        self::assertContains('brute_force', $entry->categories);
    }

    public function testAllowlistedScoredIpIsExcluded(): void
    {
        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
        $this->insertScore('203.0.113.5', $bruteForceId, 2.0);
        $this->insertAllowIp('203.0.113.5');

        $blocklist = $this->buildFor('paranoid');

        self::assertCount(0, $blocklist->entries);
    }

    public function testManualSubnetSuppressesScoredSingleIpInside(): void
    {
        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
        $this->insertScore('198.51.100.42', $bruteForceId, 2.0);
        $this->insertManualSubnet('198.51.100.0/24');

        $blocklist = $this->buildFor('paranoid');

        self::assertCount(1, $blocklist->entries);
        self::assertSame('198.51.100.0/24', $blocklist->entries[0]->ipOrCidr);
        self::assertTrue($blocklist->entries[0]->isCidr);
        self::assertSame('manual', $blocklist->entries[0]->reason);
    }

    public function testV4EntriesSortedBeforeV6(): void
    {
        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
        $this->insertScore('2001:db8::1', $bruteForceId, 2.0);
        $this->insertScore('203.0.113.5', $bruteForceId, 2.0);

        $blocklist = $this->buildFor('paranoid');

        self::assertCount(2, $blocklist->entries);
        self::assertTrue($blocklist->entries[0]->isIpv4);
        self::assertFalse($blocklist->entries[1]->isIpv4);
    }

    public function testManualSingleIpAndScoredSamePrefersScored(): void
    {
        $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
        $this->insertScore('203.0.113.7', $bruteForceId, 2.0);
        $this->insertManualIp('203.0.113.7');

        $blocklist = $this->buildFor('paranoid');

        self::assertCount(1, $blocklist->entries);
        self::assertSame('scored', $blocklist->entries[0]->reason);
    }

    private function buildFor(string $policyName): \App\Domain\Reputation\Blocklist
    {
        /** @var PolicyRepository $policies */
        $policies = $this->container->get(PolicyRepository::class);
        $policy = $policies->findByName($policyName);
        self::assertNotNull($policy);

        /** @var BlocklistBuilder $builder */
        $builder = $this->container->get(BlocklistBuilder::class);

        return $builder->build($policy);
    }

    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', 't');
        $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', 't');
        $stmt->executeStatement();
    }

    private function insertAllowIp(string $ip): void
    {
        $bin = IpAddress::fromString($ip)->binary();
        $stmt = $this->db->prepare(
            'INSERT INTO allowlist (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)'
        );
        $stmt->bindValue('kind', 'ip');
        $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT);
        $stmt->bindValue('reason', 't');
        $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();
    }
}
