<?php

declare(strict_types=1);

namespace App\Application\Public;

use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditContext;
use App\Domain\Audit\AuditEmitter;
use App\Domain\Auth\AuthenticatedPrincipal;
use App\Domain\Auth\TokenKind;
use App\Domain\Reputation\Blocklist;
use App\Domain\Reputation\BlocklistEntry;
use App\Domain\Settings\AppSettings;
use App\Infrastructure\Consumer\ConsumerRepository;
use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
use App\Infrastructure\Policy\PolicyRepository;
use App\Infrastructure\Reputation\BlocklistCache;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * `GET /api/v1/blocklist` — distribution endpoint for firewalls/proxies.
 *
 * Auth: must be a `consumer`-kind token. Wrong kind returns 401 (uniform
 * unauthorized envelope per M03).
 *
 * Output:
 *   - default `text/plain`: one entry per line, bare IP or CIDR. Empty
 *     body when there are no entries.
 *   - `?format=json`: array of `{ip_or_cidr, categories, score, reason}`.
 *
 * Headers:
 *   - `ETag: "<sha256-hex>"` of the rendered body. The body excludes
 *     `generated_at` so the ETag depends only on data, not on time.
 *   - `If-None-Match` honored — strict comparison, weak validators (`W/`)
 *     are stripped before comparing.
 *   - `X-Blocklist-Generated-At`, `X-Blocklist-Entries`, `X-Blocklist-Policy`.
 *
 * The Blocklist build is cached by `BlocklistCache` per policy_id for 30s
 * (TTL configurable in settings); the rendering layer here is fast so we
 * render fresh on each request from the cached domain object.
 */
final class BlocklistController
{
    public function __construct(
        private readonly ConsumerRepository $consumers,
        private readonly PolicyRepository $policies,
        private readonly BlocklistCache $blocklistCache,
        private readonly AuditEmitter $audit,
        private readonly AppSettings $settings,
    ) {
    }

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);
        if (!$principal instanceof AuthenticatedPrincipal || $principal->tokenKind !== TokenKind::Consumer) {
            return self::error($response, 401, 'unauthorized');
        }
        if ($principal->consumerId === null) {
            return self::error($response, 401, 'unauthorized');
        }

        $consumer = $this->consumers->findById($principal->consumerId);
        if ($consumer === null || !$consumer->isActive) {
            return self::error($response, 401, 'unauthorized');
        }

        $policy = $this->policies->findById($consumer->policyId);
        if ($policy === null) {
            // FK is RESTRICT, but defensive: a consumer pointing at a
            // missing policy is a 500-class problem.
            return self::error($response, 500, 'consumer_policy_missing');
        }

        $params = $request->getQueryParams();
        $isJson = isset($params['format']) && $params['format'] === 'json';
        $format = $isJson ? 'json' : 'text';
        $contentType = $isJson ? 'application/json' : 'text/plain';

        // SEC_REVIEW F70: render-and-hash once per (policy, format)
        // per cache window. Without this, every request paid the
        // full sha256-of-50k-entries cost regardless of cache state
        // — at the per-token rate limit (60 req/s) a single
        // consumer pinned a worker on this hash sustained.
        $rendered = $this->blocklistCache->getRendered(
            $policy,
            $format,
            $isJson
                ? static fn (Blocklist $b): string => self::renderJson($b)
                : static fn (Blocklist $b): string => self::renderText($b),
        );
        $blocklist = $rendered['blocklist'];
        $body = $rendered['body'];
        $etag = $rendered['etag'];

        $ifNoneMatch = self::extractIfNoneMatch($request);
        $notModified = $ifNoneMatch !== null && self::etagMatches($ifNoneMatch, $etag);
        $status = $notModified ? 304 : 200;

        if (
            $consumer->auditEnabled
            && $this->settings->getBool(AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED, true)
        ) {
            $this->audit->emit(
                AuditAction::BLOCKLIST_REQUESTED,
                'blocklist',
                $policy->id,
                [
                    'consumer_id' => $consumer->id,
                    'consumer_name' => $consumer->name,
                    'policy_id' => $policy->id,
                    'policy_name' => $policy->name,
                    'entries' => $blocklist->count(),
                    'format' => $isJson ? 'json' : 'text',
                    'status' => $status,
                ],
                self::auditContext($request),
                $policy->name,
            );
        }

        if ($notModified) {
            return $response
                ->withStatus(304)
                ->withHeader('ETag', $etag)
                ->withHeader('X-Blocklist-Generated-At', $blocklist->generatedAt->format('Y-m-d\TH:i:s\Z'))
                ->withHeader('X-Blocklist-Entries', (string) $blocklist->count())
                ->withHeader('X-Blocklist-Policy', $blocklist->policyName);
        }

        $response->getBody()->write($body);

        return $response
            ->withStatus(200)
            ->withHeader('Content-Type', $contentType)
            ->withHeader('ETag', $etag)
            ->withHeader('X-Blocklist-Generated-At', $blocklist->generatedAt->format('Y-m-d\TH:i:s\Z'))
            ->withHeader('X-Blocklist-Entries', (string) $blocklist->count())
            ->withHeader('X-Blocklist-Policy', $blocklist->policyName);
    }

    private static function auditContext(ServerRequestInterface $request): AuditContext
    {
        $ctx = $request->getAttribute(AuditContextMiddleware::ATTR_AUDIT_CONTEXT);

        return $ctx instanceof AuditContext ? $ctx : AuditContext::system();
    }

    private static function renderText(Blocklist $blocklist): string
    {
        if ($blocklist->entries === []) {
            return '';
        }

        $lines = array_map(static fn (BlocklistEntry $e): string => $e->ipOrCidr, $blocklist->entries);

        return implode("\n", $lines) . "\n";
    }

    private static function renderJson(Blocklist $blocklist): string
    {
        $payload = array_map(
            static fn (BlocklistEntry $e): array => $e->toJsonArray(),
            $blocklist->entries
        );

        return (string) json_encode($payload);
    }

    private static function extractIfNoneMatch(ServerRequestInterface $request): ?string
    {
        $header = $request->getHeaderLine('If-None-Match');
        if ($header === '') {
            return null;
        }

        return $header;
    }

    /**
     * RFC 7232: If-None-Match may be a comma-separated list of validators,
     * each optionally weak (`W/`). We strip weak prefixes and compare
     * strong-equality against our hash. The asterisk `*` matches anything.
     */
    private static function etagMatches(string $headerValue, string $etag): bool
    {
        $headerValue = trim($headerValue);
        if ($headerValue === '*') {
            return true;
        }
        foreach (explode(',', $headerValue) as $part) {
            $candidate = trim($part);
            if (str_starts_with($candidate, 'W/')) {
                $candidate = substr($candidate, 2);
            }
            if ($candidate === $etag) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function json(ResponseInterface $response, int $status, array $payload): ResponseInterface
    {
        $response = $response->withStatus($status)->withHeader('Content-Type', 'application/json');
        $response->getBody()->write((string) json_encode($payload));

        return $response;
    }

    private static function error(ResponseInterface $response, int $status, string $message): ResponseInterface
    {
        return self::json($response, $status, ['error' => $message]);
    }
}
