<?php

declare(strict_types=1);

namespace App\Tests\Unit\Reputation;

use App\Domain\Ip\IpAddress;
use App\Domain\Policy\EvaluationOutcome;
use App\Domain\Policy\Policy;
use App\Domain\Policy\PolicyEvaluator;
use App\Domain\Reputation\CidrEvaluator;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;

/**
 * Unit-level coverage for the score-vs-policy evaluator. Hand-built
 * `CidrEvaluator` snapshots avoid touching the DB.
 */
final class PolicyEvaluatorTest extends TestCase
{
    public function testAllowlistShortCircuitsEverything(): void
    {
        $ip = IpAddress::fromString('203.0.113.5');
        $cidr = new CidrEvaluator(
            manualIpBins: [$ip->binary()],
            manualSubnets: [],
            allowlistIpBins: [$ip->binary()],
            allowlistSubnets: [],
        );
        $policy = $this->makePolicy([1 => 0.5], includeManual: true);
        $evaluator = new PolicyEvaluator($policy, $cidr);

        $result = $evaluator->evaluate($ip, [1 => 5.0]);

        self::assertSame(EvaluationOutcome::ExcludedByAllowlist, $result->outcome);
    }

    public function testIncludedByScoreWhenAnyCategoryMeetsThreshold(): void
    {
        $ip = IpAddress::fromString('203.0.113.5');
        $cidr = new CidrEvaluator([], [], [], []);
        $policy = $this->makePolicy([1 => 1.0, 2 => 5.0]);
        $evaluator = new PolicyEvaluator($policy, $cidr);

        $result = $evaluator->evaluate($ip, [1 => 1.5, 2 => 0.1]);

        self::assertSame(EvaluationOutcome::IncludedByScore, $result->outcome);
        self::assertSame([1], $result->matchedCategoryIds);
        self::assertEqualsWithDelta(1.5, $result->maxScore, 1e-6);
    }

    public function testIncludedByScoreWithMultipleMatches(): void
    {
        $ip = IpAddress::fromString('203.0.113.5');
        $cidr = new CidrEvaluator([], [], [], []);
        $policy = $this->makePolicy([1 => 1.0, 2 => 0.5]);
        $evaluator = new PolicyEvaluator($policy, $cidr);

        $result = $evaluator->evaluate($ip, [1 => 2.0, 2 => 3.0]);

        self::assertSame(EvaluationOutcome::IncludedByScore, $result->outcome);
        self::assertSame([1, 2], $result->matchedCategoryIds);
        self::assertEqualsWithDelta(3.0, $result->maxScore, 1e-6);
    }

    public function testIncludedByManualBlockOnlyWhenPolicyIncludesIt(): void
    {
        $ip = IpAddress::fromString('203.0.113.5');
        $cidr = new CidrEvaluator(
            manualIpBins: [$ip->binary()],
            manualSubnets: [],
            allowlistIpBins: [],
            allowlistSubnets: [],
        );
        $policyIncluding = $this->makePolicy([1 => 5.0], includeManual: true);
        $policyExcluding = $this->makePolicy([1 => 5.0], includeManual: false);

        $including = (new PolicyEvaluator($policyIncluding, $cidr))->evaluate($ip, [1 => 0.0]);
        $excluding = (new PolicyEvaluator($policyExcluding, $cidr))->evaluate($ip, [1 => 0.0]);

        self::assertSame(EvaluationOutcome::IncludedByManualBlock, $including->outcome);
        self::assertSame(EvaluationOutcome::Excluded, $excluding->outcome);
    }

    public function testCategoryAbsentFromPolicyIsIgnored(): void
    {
        $ip = IpAddress::fromString('203.0.113.5');
        $cidr = new CidrEvaluator([], [], [], []);
        // policy only cares about category 1; even a sky-high score on
        // category 2 must be ignored.
        $policy = $this->makePolicy([1 => 1.0]);
        $evaluator = new PolicyEvaluator($policy, $cidr);

        $result = $evaluator->evaluate($ip, [2 => 999.0]);

        self::assertSame(EvaluationOutcome::Excluded, $result->outcome);
    }

    /**
     * @param array<int, float> $thresholds
     */
    private function makePolicy(array $thresholds, bool $includeManual = true): Policy
    {
        return new Policy(
            id: 1,
            name: 'test',
            description: null,
            includeManualBlocks: $includeManual,
            thresholds: $thresholds,
            createdAt: new DateTimeImmutable('now', new DateTimeZone('UTC')),
        );
    }
}
