<?php

declare(strict_types=1);

namespace App\Infrastructure\Reputation;

use App\Domain\Ip\IpAddress;
use App\Domain\Reputation\CidrEvaluator;
use App\Domain\Time\Clock;
use App\Infrastructure\Allowlist\AllowlistRepository;
use App\Infrastructure\ManualBlock\ManualBlockRepository;
use Psr\Log\LoggerInterface;

/**
 * Lazily-built, in-process cached `CidrEvaluator`.
 *
 * SPEC §M06: snapshot the current manual_blocks + allowlist tables and
 * cache the evaluator for `CIDR_EVALUATOR_TTL_SECONDS` (default 60s) so
 * the distribution endpoint and IP-detail page don't re-query the DB on
 * every request. Mutation endpoints call `invalidate()` after a successful
 * write to make changes visible immediately on the same replica.
 *
 * Multi-replica caveat: the cache is per-process. Other replicas keep
 * their stale snapshot until their own TTL elapses (see PROGRESS.md M06).
 */
class CidrEvaluatorFactory
{
    private ?CidrEvaluator $cached = null;
    private ?float $expiresAt = null;
    /**
     * Toggled to true if the most recent build observed an IP/subnet
     * matching both lists. Logged once, then re-checked on every rebuild.
     */
    private bool $overlapWarned = false;

    public function __construct(
        private readonly ManualBlockRepository $manualBlocks,
        private readonly AllowlistRepository $allowlist,
        private readonly Clock $clock,
        private readonly LoggerInterface $logger,
        private readonly int $ttlSeconds = 60,
    ) {
    }

    public function get(): CidrEvaluator
    {
        $now = (float) $this->clock->now()->getTimestamp();
        if ($this->cached !== null && $this->expiresAt !== null && $now < $this->expiresAt) {
            return $this->cached;
        }

        $manualIps = [];
        foreach ($this->manualBlocks->listIps() as $entry) {
            if ($entry->ip !== null) {
                $manualIps[] = $entry->ip->binary();
            }
        }
        $manualSubnets = [];
        foreach ($this->manualBlocks->listSubnets() as $entry) {
            if ($entry->cidr !== null) {
                $manualSubnets[] = $entry->cidr;
            }
        }

        $allowIps = [];
        foreach ($this->allowlist->listIps() as $entry) {
            if ($entry->ip !== null) {
                $allowIps[] = $entry->ip->binary();
            }
        }
        $allowSubnets = [];
        foreach ($this->allowlist->listSubnets() as $entry) {
            if ($entry->cidr !== null) {
                $allowSubnets[] = $entry->cidr;
            }
        }

        $evaluator = new CidrEvaluator($manualIps, $manualSubnets, $allowIps, $allowSubnets);
        $this->warnOnOverlap($allowIps, $manualIps, $allowSubnets, $manualSubnets);

        $this->cached = $evaluator;
        $this->expiresAt = $now + $this->ttlSeconds;

        return $evaluator;
    }

    public function invalidate(): void
    {
        $this->cached = null;
        $this->expiresAt = null;
        $this->overlapWarned = false;
    }

    /**
     * SPEC §M06: when an IP or subnet appears on both lists, allowlist
     * still wins — but we log a WARNING so the operator knows the manual
     * block is dead-letter. We log the first overlap we find per cache
     * cycle to avoid log spam on large overlapping lists.
     *
     * @param list<string>            $allowIpBins
     * @param list<string>            $manualIpBins
     * @param list<\App\Domain\Ip\Cidr> $allowSubnets
     * @param list<\App\Domain\Ip\Cidr> $manualSubnets
     */
    private function warnOnOverlap(
        array $allowIpBins,
        array $manualIpBins,
        array $allowSubnets,
        array $manualSubnets,
    ): void {
        if ($this->overlapWarned) {
            return;
        }

        $allowIpSet = [];
        foreach ($allowIpBins as $b) {
            $allowIpSet[$b] = true;
        }
        foreach ($manualIpBins as $b) {
            if (isset($allowIpSet[$b])) {
                $ip = IpAddress::fromBinary($b);
                $this->logger->warning(
                    sprintf(
                        'IP %s is on both allowlist and manual block list; allowlist takes precedence',
                        $ip->text()
                    )
                );
                $this->overlapWarned = true;

                return;
            }
        }

        // Cross-check: any IP on one list that's *contained* in a subnet
        // on the other counts as overlap too. Cheap because the lists are
        // small per SPEC §15.
        foreach ($manualIpBins as $b) {
            $ip = IpAddress::fromBinary($b);
            foreach ($allowSubnets as $cidr) {
                if ($cidr->contains($ip)) {
                    $this->logger->warning(
                        sprintf(
                            'IP %s is on both allowlist and manual block list; allowlist takes precedence',
                            $ip->text()
                        )
                    );
                    $this->overlapWarned = true;

                    return;
                }
            }
        }
        foreach ($allowIpBins as $b) {
            $ip = IpAddress::fromBinary($b);
            foreach ($manualSubnets as $cidr) {
                if ($cidr->contains($ip)) {
                    $this->logger->warning(
                        sprintf(
                            'IP %s is on both allowlist and manual block list; allowlist takes precedence',
                            $ip->text()
                        )
                    );
                    $this->overlapWarned = true;

                    return;
                }
            }
        }
    }
}
