<?php

declare(strict_types=1);

namespace App\Infrastructure\Reputation;

use App\Domain\Policy\Policy;
use App\Domain\Reputation\Blocklist;
use App\Domain\Reputation\BlocklistBuilder;
use App\Domain\Time\Clock;

/**
 * Per-policy in-process cache of `Blocklist` results.
 *
 * SPEC §M07.3: 30-second TTL by default, keyed on `policy_id`. Cache stores
 * the domain `Blocklist` object — text and JSON renderings happen
 * downstream and share the same cached build (the build is the expensive
 * part).
 *
 * Invalidation: mutation paths call `invalidate($policyId)` for policy-
 * scoped changes and `invalidateAll()` for global changes (manual blocks,
 * allowlist, "rebuild scores"). Tests can pass `ttlSeconds=0` to disable
 * caching entirely.
 *
 * Multi-replica caveat: the cache is per-process. The cidr-evaluator
 * factory has the same caveat (see PROGRESS.md M06).
 */
class BlocklistCache
{
    /**
     * SEC_REVIEW F71: max distinct policies kept in the cache map.
     * Admins can create unbounded numbers of policies; without this
     * cap the cache grew monotonically over the worker's lifetime.
     * 16 covers any realistic deployment (one policy per consumer
     * tier is the typical pattern) while bounding worst-case memory
     * to ~16 × the per-policy build cost.
     */
    public const MAX_ENTRIES = 16;

    /**
     * @var array<int, array{blocklist: Blocklist, expires_at: float, rendered: array<string, array{body: string, etag: string}>}>
     */
    private array $cache = [];

    public function __construct(
        private readonly BlocklistBuilder $builder,
        private readonly Clock $clock,
        private readonly int $ttlSeconds = 30,
        private readonly int $maxEntries = self::MAX_ENTRIES,
    ) {
    }

    public function getOrBuild(Policy $policy): Blocklist
    {
        $now = (float) $this->clock->now()->getTimestamp();
        $entry = $this->cache[$policy->id] ?? null;
        if ($entry !== null && $now < $entry['expires_at']) {
            // SEC_REVIEW F71: touch the entry on read so the LRU
            // eviction sees it as recently used.
            $this->touch($policy->id);

            return $entry['blocklist'];
        }

        $blocklist = $this->builder->build($policy);
        if ($this->ttlSeconds > 0) {
            $this->store($policy->id, [
                'blocklist' => $blocklist,
                'expires_at' => $now + $this->ttlSeconds,
                'rendered' => [],
            ]);
        }

        return $blocklist;
    }

    /**
     * SEC_REVIEW F70: cache the rendered body and its sha256 ETag per
     * (policy_id, format), not just the underlying `Blocklist` domain
     * object. Without this, every cache hit rebuilt the rendered
     * string and re-hashed it — at 50k entries per blocklist that's
     * a multi-megabyte sha256 per request, sustained at the
     * per-token rate limit (60 req/s). Now the render+hash runs
     * once per (policy, format) per cache window; subsequent
     * requests within the window reuse the cached `body` + `etag`
     * verbatim and the per-request cost collapses to "send the
     * bytes, return the headers".
     *
     * `$render` is invoked at most once per cache miss and receives
     * the (possibly freshly-built) `Blocklist` object so the
     * controller can keep its rendering logic colocated.
     *
     * @param callable(Blocklist): string $render
     * @return array{body: string, etag: string, blocklist: Blocklist}
     */
    public function getRendered(Policy $policy, string $format, callable $render): array
    {
        $now = (float) $this->clock->now()->getTimestamp();
        $entry = $this->cache[$policy->id] ?? null;
        $valid = $entry !== null && $now < $entry['expires_at'];

        if (!$valid) {
            $blocklist = $this->builder->build($policy);
            $entry = [
                'blocklist' => $blocklist,
                'expires_at' => $now + max(0, $this->ttlSeconds),
                'rendered' => [],
            ];
        }

        if (!isset($entry['rendered'][$format])) {
            $body = $render($entry['blocklist']);
            $entry['rendered'][$format] = [
                'body' => $body,
                'etag' => '"' . hash('sha256', $body) . '"',
            ];
        }

        if ($this->ttlSeconds > 0) {
            // SEC_REVIEW F71: write through `store()` so the LRU
            // eviction marks this entry most-recently-used and drops
            // the oldest entry past `maxEntries`.
            $this->store($policy->id, $entry);
        }

        return [
            'body' => $entry['rendered'][$format]['body'],
            'etag' => $entry['rendered'][$format]['etag'],
            'blocklist' => $entry['blocklist'],
        ];
    }

    public function invalidate(int $policyId): void
    {
        unset($this->cache[$policyId]);
    }

    public function invalidateAll(): void
    {
        $this->cache = [];
    }

    /**
     * Number of policies currently held in the cache. Test-only:
     * lets a regression spec assert the LRU bound is enforced
     * without reaching into the private array via reflection.
     *
     * @internal
     */
    public function entryCount(): int
    {
        return count($this->cache);
    }

    /**
     * Test-only: the policy IDs in the cache, in LRU-eviction order
     * (oldest first, most-recently-used last).
     *
     * @return list<int>
     * @internal
     */
    public function cachedPolicyIds(): array
    {
        return array_keys($this->cache);
    }

    /**
     * SEC_REVIEW F71: write a cache entry and enforce the
     * `maxEntries` LRU bound. PHP arrays preserve insertion order,
     * so unsetting then assigning the key pushes it to the end of
     * the iteration order — making `array_key_first()` the
     * least-recently-used entry to evict.
     *
     * @param array{blocklist: Blocklist, expires_at: float, rendered: array<string, array{body: string, etag: string}>} $entry
     */
    private function store(int $policyId, array $entry): void
    {
        unset($this->cache[$policyId]);
        $this->cache[$policyId] = $entry;
        while (count($this->cache) > $this->maxEntries) {
            $oldest = array_key_first($this->cache);
            if ($oldest === null) {
                break;
            }
            unset($this->cache[$oldest]);
        }
    }

    /**
     * Mark an existing entry as most-recently-used. No-op if the
     * entry isn't in the cache.
     */
    private function touch(int $policyId): void
    {
        if (!array_key_exists($policyId, $this->cache)) {
            return;
        }
        $entry = $this->cache[$policyId];
        unset($this->cache[$policyId]);
        $this->cache[$policyId] = $entry;
    }
}
