<?php

declare(strict_types=1);

namespace App\Tests\Integration\Jobs;

use App\Application\Jobs\RecomputeScoresJob;
use App\Domain\Ip\IpAddress;
use App\Domain\Jobs\JobContext;
use App\Domain\Reputation\PairScorer;
use App\Domain\Time\FixedClock;
use App\Infrastructure\Category\CategoryRepository;
use App\Infrastructure\Reputation\IpScoreRepository;
use App\Infrastructure\Reputation\ReportRepository;
use App\Tests\Integration\Support\AppTestCase;
use Doctrine\DBAL\ParameterType;
use Monolog\Handler\NullHandler;
use Monolog\Logger;

/**
 * Bulk recompute over real reports rows. The headline scenario is the M05
 * acceptance: clock-forward 30 days between two reports with a 14-day
 * exponential half-life and verify the score matches `0.5^(30/14) +
 * 0.5^(0/14) = 1 + ~0.226 = ~1.226`.
 */
final class RecomputeScoresJobTest extends AppTestCase
{
    public function testFullModeAppliesDecayAcross30DayWindow(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');

        // 30 days ago: a report
        $reporterId = $this->createReporter('decay-test');
        $ip = IpAddress::fromString('203.0.113.42');
        /** @var int $catId */
        $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='brute_force'");
        self::assertGreaterThan(0, $catId);

        $thirtyDaysAgo = $clock->now()->modify('-30 days');
        $this->insertReport($ip, $catId, $reporterId, 1.0, $thirtyDaysAgo);
        // and a fresh report just now
        $this->insertReport($ip, $catId, $reporterId, 1.0, $clock->now());

        // Pre-existing ip_scores row so the recompute touches it. (In real
        // life the synchronous ingest path would have created this; for an
        // isolated test we simulate with a stale row.)
        $this->db->insert('ip_scores', [
            'ip_bin' => $ip->binary(),
            'ip_text' => $ip->text(),
            'category_id' => $catId,
            'score' => '0.0000',
            'report_count_30d' => 0,
            'last_report_at' => $thirtyDaysAgo->format('Y-m-d H:i:s'),
            'recomputed_at' => $thirtyDaysAgo->format('Y-m-d H:i:s'),
        ], ['ip_bin' => ParameterType::LARGE_OBJECT]);

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

        self::assertGreaterThan(0, $result->itemsProcessed);

        $newScore = (float) $this->db->fetchOne(
            'SELECT score FROM ip_scores WHERE ip_bin = :bin AND category_id = :cat',
            ['bin' => $ip->binary(), 'cat' => $catId],
            ['bin' => ParameterType::LARGE_OBJECT, 'cat' => ParameterType::INTEGER],
        );

        // Expected: 1.0 (fresh) + 0.5^(30/14) (30-day-old) ≈ 1.2266.
        $expected = 1.0 + 0.5 ** (30.0 / 14.0);
        self::assertEqualsWithDelta($expected, $newScore, 0.001);
    }

    public function testIncrementalModePicksUpRecentlyTouchedPairs(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $reporterId = $this->createReporter('inc-test');
        $ip = IpAddress::fromString('198.51.100.7');
        /** @var int $catId */
        $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='scanner'");

        $this->insertReport($ip, $catId, $reporterId, 1.0, $clock->now()->modify('-2 minutes'));

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

        self::assertGreaterThanOrEqual(1, $result->itemsProcessed);

        $score = (float) $this->db->fetchOne(
            'SELECT score FROM ip_scores WHERE ip_bin = :bin AND category_id = :cat',
            ['bin' => $ip->binary(), 'cat' => $catId],
            ['bin' => ParameterType::LARGE_OBJECT, 'cat' => ParameterType::INTEGER],
        );
        // Fresh report → score ~ 1.0
        self::assertEqualsWithDelta(1.0, $score, 0.01);
    }

    public function testDropsStaleNearZeroPairs(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $ip = IpAddress::fromString('192.0.2.99');
        /** @var int $catId */
        $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='spam'");

        // ip_scores row with a score below threshold and an ancient
        // last_report_at — meets the drop rule.
        $this->db->insert('ip_scores', [
            'ip_bin' => $ip->binary(),
            'ip_text' => $ip->text(),
            'category_id' => $catId,
            'score' => '0.0001',
            'report_count_30d' => 0,
            'last_report_at' => $clock->now()->modify('-200 days')->format('Y-m-d H:i:s'),
            'recomputed_at' => $clock->now()->modify('-200 days')->format('Y-m-d H:i:s'),
        ], ['ip_bin' => ParameterType::LARGE_OBJECT]);

        // No reports → recompute will set last_report_at to (now - 366d)
        // → drop rule fires.
        $job = $this->buildJob($clock);
        $logger = new Logger('test');
        $logger->pushHandler(new NullHandler());
        $job->run(new JobContext($clock, $logger, ['full' => true]));

        $remaining = (int) $this->db->fetchOne(
            'SELECT COUNT(*) FROM ip_scores WHERE ip_bin = :bin AND category_id = :cat',
            ['bin' => $ip->binary(), 'cat' => $catId],
            ['bin' => ParameterType::LARGE_OBJECT, 'cat' => ParameterType::INTEGER],
        );
        self::assertSame(0, $remaining);
    }

    private function insertReport(IpAddress $ip, int $catId, int $reporterId, float $weight, \DateTimeImmutable $when): void
    {
        $this->db->insert('reports', [
            'ip_bin' => $ip->binary(),
            'ip_text' => $ip->text(),
            'category_id' => $catId,
            'reporter_id' => $reporterId,
            'weight_at_report' => number_format($weight, 2, '.', ''),
            'metadata_json' => null,
            'received_at' => $when->format('Y-m-d H:i:s'),
        ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
    }

    private function buildJob(FixedClock $clock): RecomputeScoresJob
    {
        $reports = new ReportRepository($this->db);
        $ipScores = new IpScoreRepository($this->db);
        $categories = new CategoryRepository($this->db);
        $scorer = new PairScorer($reports, $categories, $clock, 365);

        return new RecomputeScoresJob($reports, $ipScores, $scorer);
    }
}
