<?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\Ip\InvalidIpException;
use App\Domain\Ip\IpAddress;
use App\Domain\Reputation\PairScorer;
use App\Domain\Settings\AppSettings;
use App\Domain\Time\Clock;
use App\Infrastructure\Category\CategoryRepository;
use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
use App\Infrastructure\Reporter\ReporterRepository;
use App\Infrastructure\Reputation\IpScoreRepository;
use App\Infrastructure\Reputation\ReportRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * `POST /api/v1/report` — the only public ingest path.
 *
 * Order of operations:
 *  1. Reject any non-reporter token kind with 401 (per the M03 convention
 *     that wrong-kind == generic unauthorized).
 *  2. Validate the body — IP, category slug, optional metadata (≤4 KB).
 *  3. Look up the reporter via the principal's `reporterId` and refuse if
 *     it's been deactivated.
 *  4. Insert the report (append-only) and synchronously upsert
 *     `ip_scores` for the touched (ip, category) pair via PairScorer.
 *  5. Return 202 with the report id and received_at.
 *
 * The synchronous upsert is correct for this single pair only; ageing of
 * untouched scores is M05's recompute job.
 */
final class ReportController
{
    private const METADATA_MAX_BYTES = 4096;

    public function __construct(
        private readonly ReporterRepository $reporters,
        private readonly CategoryRepository $categories,
        private readonly ReportRepository $reports,
        private readonly IpScoreRepository $ipScores,
        private readonly PairScorer $scorer,
        private readonly Clock $clock,
        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::Reporter) {
            return self::error($response, 401, 'unauthorized');
        }
        if ($principal->reporterId === null) {
            return self::error($response, 401, 'unauthorized');
        }

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

        $body = self::jsonBody($request);
        $errors = [];

        $ipRaw = isset($body['ip']) && is_string($body['ip']) ? trim($body['ip']) : '';
        $ip = null;
        if ($ipRaw === '') {
            $errors['ip'] = 'required';
        } else {
            try {
                $ip = IpAddress::fromString($ipRaw);
            } catch (InvalidIpException) {
                $errors['ip'] = 'invalid';
            }
        }

        $categorySlug = isset($body['category']) && is_string($body['category']) ? trim($body['category']) : '';
        $category = null;
        if ($categorySlug === '') {
            $errors['category'] = 'required';
        } else {
            $category = $this->categories->findActiveBySlug($categorySlug);
            if ($category === null) {
                $errors['category'] = 'unknown or inactive';
            }
        }

        $metadataJson = null;
        if (array_key_exists('metadata', $body) && $body['metadata'] !== null) {
            if (!is_array($body['metadata']) || array_is_list($body['metadata'])) {
                $errors['metadata'] = 'must be a JSON object';
            } else {
                $encoded = json_encode($body['metadata']);
                if ($encoded === false) {
                    $errors['metadata'] = 'not encodable as JSON';
                } elseif (strlen($encoded) > self::METADATA_MAX_BYTES) {
                    $errors['metadata'] = sprintf('exceeds %d bytes', self::METADATA_MAX_BYTES);
                } else {
                    $metadataJson = $encoded;
                }
            }
        }

        if ($errors !== []) {
            return self::json($response, 400, [
                'error' => 'validation_failed',
                'details' => $errors,
            ]);
        }

        /** @var IpAddress $ip */
        /** @var \App\Domain\Category\Category $category */
        $now = $this->clock->now();
        $reportId = $this->reports->insert(
            ipBin: $ip->binary(),
            ipText: $ip->text(),
            categoryId: $category->id,
            reporterId: $reporter->id,
            weightAtReport: $reporter->trustWeight,
            metadataJson: $metadataJson,
            receivedAt: $now,
        );

        $score = $this->scorer->score($ip->binary(), $category->id, $now);
        $count30d = $this->reports->recentCount($ip->binary(), $category->id, $now->modify('-30 days'));
        $this->ipScores->upsert(
            ipBin: $ip->binary(),
            ipText: $ip->text(),
            categoryId: $category->id,
            score: $score,
            reportCount30d: $count30d,
            lastReportAt: $now,
            recomputedAt: $now,
        );

        if (
            $reporter->auditEnabled
            && $this->settings->getBool(AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED, true)
        ) {
            $this->audit->emit(
                AuditAction::REPORT_RECEIVED,
                'report',
                $reportId,
                [
                    'ip' => $ip->text(),
                    'category' => $category->slug,
                    'reporter_id' => $reporter->id,
                    'reporter_name' => $reporter->name,
                    'has_metadata' => $metadataJson !== null,
                ],
                self::auditContext($request),
                $ip->text(),
            );
        }

        return self::json($response, 202, [
            'report_id' => $reportId,
            'ip' => $ip->text(),
            'received_at' => $now->format('Y-m-d\TH:i:s\Z'),
        ]);
    }

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

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

    /**
     * SEC_REVIEW F69: bounded `json_decode` depth. PHP's default is
     * 512 — enough for an attacker who slipped past Caddy's
     * `request_body { max_size }` and the
     * RequestBodySizeLimitMiddleware byte cap to amplify a small
     * payload into a deep recursion. 32 levels is plenty for
     * legitimate `metadata` shapes (UA strings, URL, IP literals —
     * typically 2–3 levels deep).
     */
    private const JSON_DEPTH_CAP = 32;

    /**
     * SEC_REVIEW F69: bounded JSON parse. `JSON_THROW_ON_ERROR`
     * surfaces malformed JSON as an exception we catch and translate
     * to "treat as empty body" — preserving the existing
     * "validation_failed: ip required" UX for malformed payloads.
     *
     * @return array<string, mixed>
     */
    private static function jsonBody(ServerRequestInterface $request): array
    {
        $parsed = $request->getParsedBody();
        if (is_array($parsed)) {
            return $parsed;
        }
        $raw = (string) $request->getBody();
        if ($raw === '') {
            return [];
        }
        try {
            $decoded = json_decode($raw, true, self::JSON_DEPTH_CAP, \JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return [];
        }

        return is_array($decoded) ? $decoded : [];
    }

    /**
     * @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]);
    }
}
