<?php

declare(strict_types=1);

namespace App\Domain\Reputation;

use App\Domain\Category\Category;
use App\Domain\Ip\Cidr;
use App\Domain\Ip\IpAddress;
use App\Domain\Policy\Policy;
use App\Domain\Time\Clock;
use App\Infrastructure\Category\CategoryRepository;
use App\Infrastructure\Reputation\CidrEvaluatorFactory;
use App\Infrastructure\Reputation\IpScoreRepository;

/**
 * Builds a `Blocklist` from current data for a given `Policy`.
 *
 * Algorithm (SPEC §M07.4):
 *   1. Pull (ip_bin, category_id, score) rows whose score meets at least one
 *      threshold of the policy. Single SQL with per-category clauses joined
 *      by OR, indexed via `ip_scores(category_id)` + `ip_scores(score)`.
 *   2. Group per ip_bin → matched categories + max score.
 *   3. Drop allowlisted scored IPs.
 *   4. If the policy has `include_manual_blocks=true`:
 *        - emit one entry per manual subnet (filter subnets whose network
 *          address is allowlisted; allowlist still wins)
 *        - emit one entry per manual single-IP not on the allowlist
 *        - drop scored single-IPs that are covered by a manual subnet
 *          (broader entry covers it; SPEC dedup rule)
 *        - if a single IP is both scored and manually-listed, prefer the
 *          scored entry (keeps category attribution).
 *   5. Sort: IPv4 first, then IPv6; binary order within each (stable so the
 *      ETag on identical input is stable).
 *
 * Hot-path performance (50k scored IPs + 100 manual /24 subnets target
 * <500 ms — SPEC §M07.5): subnet-containment checks are grouped by prefix
 * length so a single `applyMask` per (ip, prefix) feeds an `isset()` hash
 * lookup. This avoids the O(IPs × subnets) `Cidr::contains()` loop.
 */
final class BlocklistBuilder
{
    public function __construct(
        private readonly IpScoreRepository $ipScores,
        private readonly CategoryRepository $categories,
        private readonly CidrEvaluatorFactory $cidrEvaluatorFactory,
        private readonly Clock $clock,
    ) {
    }

    public function build(Policy $policy): Blocklist
    {
        $now = $this->clock->now();
        $evaluator = $this->cidrEvaluatorFactory->get();

        $categoryIds = array_keys($policy->thresholds);
        $slugByCategoryId = $this->slugByCategoryId();

        // ---- 1. scored entries ----
        // The SQL query in findExceedingThresholds already filters
        // score >= threshold per category, so we trust each row here.
        // Parallel hashes (ipText / categoriesByIp / maxScoreByIp) keyed
        // on ip_bin avoid the nested-array allocations that dominated
        // the 50k path; see PROGRESS.md M07 perf notes.
        $rows = $this->ipScores->findExceedingThresholds($categoryIds, $policy->thresholds);

        /** @var array<string, string> $ipText */
        $ipText = [];
        /** @var array<string, list<int>> $categoriesByIp */
        $categoriesByIp = [];
        /** @var array<string, float> $maxScoreByIp */
        $maxScoreByIp = [];
        /** @var array<string, array<int, float>> $scoresByIp ip_bin => category_id => score */
        $scoresByIp = [];

        foreach ($rows as $row) {
            $bin = (string) $row['ip_bin'];
            $catId = (int) $row['category_id'];
            $score = (float) $row['score'];
            if (!isset($ipText[$bin])) {
                $ipText[$bin] = (string) $row['ip_text'];
                $categoriesByIp[$bin] = [$catId];
                $maxScoreByIp[$bin] = $score;
                $scoresByIp[$bin] = [$catId => $score];

                continue;
            }
            $categoriesByIp[$bin][] = $catId;
            $scoresByIp[$bin][$catId] = $score;
            if ($score > $maxScoreByIp[$bin]) {
                $maxScoreByIp[$bin] = $score;
            }
        }

        // ---- 2. allowlist filtering on scored IPs ----
        $allowIpHash = [];
        foreach ($evaluator->allowlistedIpBins() as $b) {
            $allowIpHash[$b] = true;
        }
        $allowSubnetsByPrefix = self::groupSubnetsByPrefix($evaluator->allowlistedSubnets());
        if ($allowIpHash !== []) {
            foreach ($ipText as $bin => $_) {
                if (isset($allowIpHash[$bin])) {
                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin], $scoresByIp[$bin]);
                }
            }
        }
        if ($allowSubnetsByPrefix !== []) {
            foreach ($ipText as $bin => $_) {
                if (self::binaryInSubnetIndex($bin, $allowSubnetsByPrefix)) {
                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin], $scoresByIp[$bin]);
                }
            }
        }

        // ---- 3. manual blocks ----
        /** @var list<array{ipOrCidr: string, isCidr: bool, isIpv4: bool, sortKey: string, ipBin: ?string}> $manualEntries */
        $manualEntries = [];
        $manualSubnets = $policy->includeManualBlocks ? $evaluator->manualBlockedSubnets() : [];
        $manualSubnetsByPrefix = self::groupSubnetsByPrefix($manualSubnets);

        if ($policy->includeManualBlocks) {
            foreach ($manualSubnets as $cidr) {
                // Allowlist precedence: drop the entry if the network
                // address is allowlisted (covers the common case of a
                // manual /24 inside a larger allowlist /16).
                $netBin = $cidr->network();
                if (
                    isset($allowIpHash[$netBin])
                    || self::binaryInSubnetIndex($netBin, $allowSubnetsByPrefix)
                ) {
                    continue;
                }
                $manualEntries[] = [
                    'ipOrCidr' => $cidr->text(),
                    'isCidr' => true,
                    'isIpv4' => $cidr->isIpv4(),
                    'sortKey' => $netBin,
                    'ipBin' => null,
                ];
            }

            foreach ($evaluator->manualBlockedIpBins() as $bin) {
                if (isset($allowIpHash[$bin])) {
                    continue;
                }
                if (self::binaryInSubnetIndex($bin, $allowSubnetsByPrefix)) {
                    continue;
                }
                $ip = IpAddress::fromBinary($bin);
                $manualEntries[] = [
                    'ipOrCidr' => $ip->text(),
                    'isCidr' => false,
                    'isIpv4' => $ip->isIpv4(),
                    'sortKey' => $bin,
                    'ipBin' => $bin,
                ];
            }
        }

        // ---- 4. dedup ----
        // Drop scored single-IPs covered by any manual subnet.
        if ($manualSubnetsByPrefix !== [] && $ipText !== []) {
            foreach ($ipText as $bin => $_) {
                if (self::binaryInSubnetIndex($bin, $manualSubnetsByPrefix)) {
                    unset($ipText[$bin], $categoriesByIp[$bin], $maxScoreByIp[$bin], $scoresByIp[$bin]);
                }
            }
        }

        // Drop manual single-IP entries that are also scored (prefer
        // scored — it carries category attribution).
        if ($ipText !== []) {
            $manualEntries = array_values(array_filter(
                $manualEntries,
                static fn (array $e): bool => $e['ipBin'] === null || !isset($ipText[$e['ipBin']])
            ));
        }

        // ---- 5. assemble + sort ----
        // Build two parallel buckets keyed by binary network, then concat
        // v4 first, then v6. `ksort` over the binary keys gives us the
        // SPEC-mandated lexical order without paying the usort-with-
        // closure overhead (which dominates the 50k path — see
        // PROGRESS.md M07 perf notes).
        /** @var array<string, BlocklistEntry> $v4Bucket */
        $v4Bucket = [];
        /** @var array<string, BlocklistEntry> $v6Bucket */
        $v6Bucket = [];

        foreach ($ipText as $bin => $text) {
            $categories = [];
            $categoryScores = [];
            foreach ($categoriesByIp[$bin] as $catId) {
                if (isset($slugByCategoryId[$catId])) {
                    $slug = $slugByCategoryId[$catId];
                    $categories[] = $slug;
                    $categoryScores[$slug] = round($scoresByIp[$bin][$catId], 4);
                }
            }
            sort($categories);
            $isIpv4 = self::isV4Mapped($bin);
            $entry = new BlocklistEntry(
                ipOrCidr: $text,
                isCidr: false,
                isIpv4: $isIpv4,
                categories: $categories,
                score: round($maxScoreByIp[$bin], 4),
                reason: 'scored',
                categoryScores: $categoryScores,
            );
            if ($isIpv4) {
                $v4Bucket[$bin] = $entry;
            } else {
                $v6Bucket[$bin] = $entry;
            }
        }

        foreach ($manualEntries as $m) {
            $entry = new BlocklistEntry(
                ipOrCidr: $m['ipOrCidr'],
                isCidr: $m['isCidr'],
                isIpv4: $m['isIpv4'],
                categories: [],
                score: null,
                reason: 'manual',
            );
            // Distinct keys: a manual subnet's network can collide with
            // a scored single-IP that we already dropped via dedup, but
            // not with another manual entry once dedup ran. Use a
            // length-prefix to keep keys distinct between subnet/single
            // entries that happen to share a network address.
            $key = $m['sortKey'] . ($m['isCidr'] ? "\x01" : "\x00");
            if ($m['isIpv4']) {
                $v4Bucket[$key] = $entry;
            } else {
                $v6Bucket[$key] = $entry;
            }
        }

        ksort($v4Bucket, SORT_STRING);
        ksort($v6Bucket, SORT_STRING);

        $entries = array_values($v4Bucket);
        foreach ($v6Bucket as $entry) {
            $entries[] = $entry;
        }

        return new Blocklist($entries, $policy->name, $now);
    }

    /**
     * @return array<int, string>
     */
    private function slugByCategoryId(): array
    {
        $out = [];
        foreach ($this->categories->listAll() as $cat) {
            /** @var Category $cat */
            $out[$cat->id] = $cat->slug;
        }

        return $out;
    }

    private static function isV4Mapped(string $bin): bool
    {
        return strlen($bin) === 16
            && substr($bin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff";
    }

    /**
     * Group subnets into a `prefix_length => set<network_bin>` index so a
     * single mask + isset() answers containment in O(1) per prefix length.
     *
     * @param list<Cidr> $subnets
     * @return array<int, array<string, true>>
     */
    private static function groupSubnetsByPrefix(array $subnets): array
    {
        $index = [];
        foreach ($subnets as $cidr) {
            $index[$cidr->prefixLength()][$cidr->network()] = true;
        }

        return $index;
    }

    /**
     * Walk each prefix length in `$index`, mask `$ipBin` to that prefix,
     * and check the result against the indexed network set. True iff the
     * IP is contained by any indexed subnet.
     *
     * @param array<int, array<string, true>> $index
     */
    private static function binaryInSubnetIndex(string $ipBin, array $index): bool
    {
        foreach ($index as $prefix => $networks) {
            $masked = self::applyMaskFast($ipBin, $prefix);
            if (isset($networks[$masked])) {
                return true;
            }
        }

        return false;
    }

    /**
     * Mask the lower (128 - prefix) bits to zero, returning a 16-byte
     * string. Mirrors `Cidr::applyMask` but inlined here without the
     * exception path so the hot loop avoids a method call.
     */
    private static function applyMaskFast(string $binary, int $prefix): string
    {
        if ($prefix >= 128) {
            return $binary;
        }
        if ($prefix <= 0) {
            return "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
        }

        $fullBytes = $prefix >> 3;
        $extraBits = $prefix & 7;

        if ($extraBits === 0) {
            return substr($binary, 0, $fullBytes) . str_repeat("\x00", 16 - $fullBytes);
        }

        $byte = ord($binary[$fullBytes]) & ((0xff << (8 - $extraBits)) & 0xff);

        return substr($binary, 0, $fullBytes) . chr($byte) . str_repeat("\x00", 15 - $fullBytes);
    }
}
