<?php

declare(strict_types=1);

namespace App\Tests\Integration\Jobs;

use App\Application\Jobs\CleanupExpiredManualBlocksJob;
use App\Domain\Audit\AuditAction;
use App\Domain\Ip\IpAddress;
use App\Domain\Jobs\JobContext;
use App\Domain\Reputation\BlocklistBuilder;
use App\Domain\Time\FixedClock;
use App\Infrastructure\Allowlist\AllowlistRepository;
use App\Infrastructure\Audit\AuditRepository;
use App\Infrastructure\Audit\DbAuditEmitter;
use App\Infrastructure\Category\CategoryRepository;
use App\Infrastructure\ManualBlock\ManualBlockRepository;
use App\Infrastructure\Reputation\BlocklistCache;
use App\Infrastructure\Reputation\CidrEvaluatorFactory;
use App\Infrastructure\Reputation\IpScoreRepository;
use App\Tests\Integration\Support\AppTestCase;
use Doctrine\DBAL\ParameterType;
use Monolog\Handler\NullHandler;
use Monolog\Logger;

/**
 * SPEC §M14.5: expired manual blocks are filtered at read time AND pruned
 * by the daily cleanup job, with one audit row per row deleted.
 */
final class CleanupExpiredManualBlocksJobTest extends AppTestCase
{
    public function testExpiredBlocksAreFilteredAtReadTime(): void
    {
        $clock = FixedClock::at('2026-04-29T12:00:00Z');
        $repo = new ManualBlockRepository($this->db, $clock);

        $expiredAt = $clock->now()->modify('-1 hour');
        $futureAt = $clock->now()->modify('+1 day');
        $this->insertManualIp('203.0.113.10', 'expired', $expiredAt);
        $this->insertManualIp('203.0.113.11', 'future', $futureAt);
        $this->insertManualIp('203.0.113.12', 'no-expiry', null);

        $rows = $repo->list(null, null);
        self::assertCount(2, $rows, 'expired entry should be hidden');
        $reasons = array_map(static fn ($r) => $r->reason, $rows);
        self::assertContains('future', $reasons);
        self::assertContains('no-expiry', $reasons);
        self::assertNotContains('expired', $reasons);

        // Direct lookup also filters.
        $expired = $repo->findByIpBin(IpAddress::fromString('203.0.113.10')->binary());
        self::assertNull($expired);
        $live = $repo->findByIpBin(IpAddress::fromString('203.0.113.11')->binary());
        self::assertNotNull($live);

        // count() honours the filter too.
        self::assertSame(2, $repo->count());
    }

    public function testJobDeletesExpiredAndEmitsOneAuditPerRow(): void
    {
        $clock = FixedClock::at('2026-04-29T12:00:00Z');
        $repo = new ManualBlockRepository($this->db, $clock);

        $expiredAt = $clock->now()->modify('-1 hour');
        $this->insertManualIp('203.0.113.20', 'one', $expiredAt);
        $this->insertManualIp('203.0.113.21', 'two', $expiredAt);
        $this->insertManualIp('203.0.113.22', 'three-fresh', null);

        $job = $this->buildJob($repo, $clock);
        $logger = new Logger('test');
        $logger->pushHandler(new NullHandler());
        $result = $job->run(new JobContext($clock, $logger));

        self::assertSame(2, $result->itemsProcessed);

        // Surviving row stays.
        $remaining = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM manual_blocks WHERE reason = 'three-fresh'"
        );
        self::assertSame(1, $remaining);

        // Expired rows gone.
        $remainingExpired = (int) $this->db->fetchOne(
            "SELECT COUNT(*) FROM manual_blocks WHERE reason IN ('one','two')"
        );
        self::assertSame(0, $remainingExpired);

        // Two audit rows for the deletions.
        $auditCount = (int) $this->db->fetchOne(
            'SELECT COUNT(*) FROM audit_log WHERE action = :action',
            ['action' => AuditAction::MANUAL_BLOCK_DELETED]
        );
        self::assertSame(2, $auditCount);

        $rows = $this->db->fetchAllAssociative(
            'SELECT actor_kind, target_type, details_json FROM audit_log WHERE action = :action',
            ['action' => AuditAction::MANUAL_BLOCK_DELETED]
        );
        foreach ($rows as $row) {
            self::assertSame('system', $row['actor_kind']);
            self::assertSame('manual_block', $row['target_type']);
            $payload = json_decode((string) $row['details_json'], true);
            self::assertSame('expired', $payload['reason']);
            self::assertSame(CleanupExpiredManualBlocksJob::NAME, $payload['job']);
        }
    }

    public function testNoExpiredEntriesIsAZeroNoOp(): void
    {
        $clock = FixedClock::at('2026-04-29T12:00:00Z');
        $repo = new ManualBlockRepository($this->db, $clock);
        $this->insertManualIp('203.0.113.30', 'fresh', null);

        $job = $this->buildJob($repo, $clock);
        $logger = new Logger('test');
        $logger->pushHandler(new NullHandler());
        $result = $job->run(new JobContext($clock, $logger));

        self::assertSame(0, $result->itemsProcessed);
        $auditCount = (int) $this->db->fetchOne(
            'SELECT COUNT(*) FROM audit_log WHERE action = :action',
            ['action' => AuditAction::MANUAL_BLOCK_DELETED]
        );
        self::assertSame(0, $auditCount);
    }

    private function insertManualIp(string $ip, string $reason, ?\DateTimeImmutable $expiresAt): void
    {
        $this->db->insert('manual_blocks', [
            'kind' => 'ip',
            'ip_bin' => IpAddress::fromString($ip)->binary(),
            'network_bin' => null,
            'prefix_length' => null,
            'reason' => $reason,
            'expires_at' => $expiresAt?->format('Y-m-d H:i:s'),
            'created_by_user_id' => null,
        ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
    }

    private function buildJob(ManualBlockRepository $repo, FixedClock $clock): CleanupExpiredManualBlocksJob
    {
        $logger = new Logger('test');
        $logger->pushHandler(new NullHandler());

        $auditRepo = new AuditRepository($this->db, $clock);
        $audit = new DbAuditEmitter($auditRepo, $logger);
        $allowlist = new AllowlistRepository($this->db);
        $evaluator = new CidrEvaluatorFactory($repo, $allowlist, $clock, $logger, 60);

        $categories = new CategoryRepository($this->db);
        $ipScores = new IpScoreRepository($this->db);
        $builder = new BlocklistBuilder($ipScores, $categories, $evaluator, $clock);
        $blocklistCache = new BlocklistCache($builder, $clock, 30);

        return new CleanupExpiredManualBlocksJob($repo, $audit, $evaluator, $blocklistCache);
    }
}
