<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Domain\Category\Category;
use App\Domain\Ip\InvalidIpException;
use App\Domain\Ip\IpAddress;
use App\Domain\Reputation\CidrEvaluator;
use App\Domain\Reputation\EffectiveStatus;
use App\Domain\Reputation\EffectiveStatusService;
use App\Infrastructure\Allowlist\AllowlistRepository;
use App\Infrastructure\Category\CategoryRepository;
use App\Infrastructure\ManualBlock\ManualBlockRepository;
use App\Infrastructure\Reputation\CidrEvaluatorFactory;
use App\Infrastructure\Reputation\IpEnrichmentRepository;
use App\Infrastructure\Reputation\IpHistoryRepository;
use App\Infrastructure\Reputation\IpScoreRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Admin IP browser — search + detail.
 *
 * The search composes filters against `ip_scores` (joined with
 * `ip_enrichment` when country/asn is set), with the manual-block /
 * allowlist single-IP universes pre-resolved from `CidrEvaluatorFactory`
 * so the SQL stays a single query. Subnet membership is NOT expanded
 * here — IPs that are only "covered by" a manual /24 don't appear in
 * the search unless they also have a row in `ip_scores`. The IP-detail
 * page shows the precise effective status.
 *
 * Pagination: `page` (1-indexed) + `page_size` (default 25, max 200).
 *
 * RBAC: Viewer.
 */
final class IpsController
{
    use AdminControllerSupport;

    public function __construct(
        private readonly IpScoreRepository $ipScores,
        private readonly CategoryRepository $categories,
        private readonly ManualBlockRepository $manualBlocks,
        private readonly AllowlistRepository $allowlist,
        private readonly IpEnrichmentRepository $enrichment,
        private readonly IpHistoryRepository $history,
        private readonly CidrEvaluatorFactory $cidrEvaluatorFactory,
        private readonly EffectiveStatusService $effectiveStatus,
    ) {
    }

    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $query = $request->getQueryParams();
        $filters = $this->parseSearchFilters($query);
        $errors = $filters['errors'];
        if ($errors !== []) {
            return self::validationFailed($response, $errors);
        }

        $page = $filters['page'];
        $pageSize = $filters['page_size'];

        $evaluator = $this->cidrEvaluatorFactory->get();
        $repoFilters = [
            'q' => $filters['q'],
            'category_id' => $filters['category_id'],
            'min_score' => $filters['min_score'],
            'max_score' => $filters['max_score'],
            'country' => $filters['country'],
            'asn' => $filters['asn'],
            'status' => $filters['status'],
            'manual_bins' => $evaluator->manualBlockedIpBins(),
            'allow_bins' => $evaluator->allowlistedIpBins(),
        ];

        $result = $this->ipScores->searchIps($repoFilters, $pageSize, ($page - 1) * $pageSize);
        $slugByCategoryId = $this->slugByCategoryId();

        // SEC_REVIEW F32: batch the per-row enrichment / top-category /
        // status lookups so the request stays at O(1) DB round-trips
        // regardless of page_size, instead of O(page_size). The CIDR
        // evaluator already gives O(1) in-memory hash lookups for the
        // allow / manual-block sets, so the only DB hits we need are:
        //   - one IN-list SELECT against `ip_enrichment`
        //   - one IN-list SELECT against `ip_scores` for top category
        // `max_score > 0` (already in the search result row) tells us
        // whether the IP would resolve to `Scored`, replacing the
        // per-row `hasAnyScore` query.
        $ipBins = array_map(static fn (array $row): string => $row['ip_bin'], $result['items']);
        $enrichmentByBin = $this->enrichment->findByIpBins($ipBins);
        $topCategoryByBin = $this->ipScores->topCategoryByIpBins($ipBins);

        $items = [];
        foreach ($result['items'] as $row) {
            $bin = $row['ip_bin'];
            $top = $topCategoryByBin[$bin] ?? null;
            $effective = $this->effectiveStatusFromRow($bin, (float) $row['max_score'], $evaluator);
            $items[] = [
                'ip' => $row['ip_text'],
                'is_ipv4' => str_contains($row['ip_text'], '.') && !str_contains($row['ip_text'], ':'),
                'max_score' => round($row['max_score'], 4),
                'top_category' => $top !== null ? ($slugByCategoryId[$top] ?? null) : null,
                'pair_count' => $row['pair_count'],
                'last_report_at' => $this->formatTimestamp($row['last_report_at']),
                'status' => $effective->value,
                'enrichment' => $enrichmentByBin[$bin] ?? null,
            ];
        }

        return self::json($response, 200, [
            'items' => $items,
            'page' => $page,
            'page_size' => $pageSize,
            'total' => $result['total'],
        ]);
    }

    /**
     * Country dropdown source for the IPs list page. Returns every
     * country code seen so far in `ip_enrichment` with its population.
     */
    public function countries(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        return self::json($response, 200, [
            'items' => $this->enrichment->countryCounts(),
        ]);
    }

    /**
     * @param array{ip: string} $args
     */
    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        try {
            $ip = IpAddress::fromString(rawurldecode($args['ip']));
        } catch (InvalidIpException) {
            return self::error($response, 404, 'not_found');
        }

        $bin = $ip->binary();
        $scoreRows = $this->ipScores->scoresForIp($bin);
        $slugById = $this->slugByCategoryId();

        $scores = [];
        foreach ($scoreRows as $row) {
            $scores[] = [
                'category' => $slugById[$row['category_id']] ?? null,
                'category_id' => $row['category_id'],
                'score' => round($row['score'], 4),
                'last_report_at' => $this->formatTimestamp($row['last_report_at']),
                'report_count_30d' => $row['report_count_30d'],
            ];
        }

        $manual = $this->manualBlocks->findByIpBin($bin);
        $allow = $this->allowlist->findByIpBin($bin);
        $enrichment = $this->enrichment->findByIpBin($bin) ?? [
            'country_code' => null,
            'asn' => null,
            'as_org' => null,
            'enriched_at' => null,
        ];
        $status = $this->effectiveStatus->forIp($ip);
        $history = $this->history->forIp($bin);

        return self::json($response, 200, [
            'ip' => $ip->text(),
            'is_ipv4' => $ip->isIpv4(),
            'scores' => $scores,
            'enrichment' => $enrichment,
            'status' => $status->value,
            'manual_block' => $manual?->toArray(),
            'allowlist' => $allow?->toArray(),
            'history' => $history['items'],
            'has_more' => $history['has_more'],
        ]);
    }

    /**
     * @param array<string, mixed> $query
     * @return array{
     *     errors: array<string, string>,
     *     page: int,
     *     page_size: int,
     *     q: ?string,
     *     category_id: ?int,
     *     min_score: ?float,
     *     max_score: ?float,
     *     country: ?string,
     *     asn: ?int,
     *     status: ?string,
     * }
     */
    private function parseSearchFilters(array $query): array
    {
        $errors = [];
        $page = isset($query['page']) && ctype_digit((string) $query['page']) ? max(1, (int) $query['page']) : 1;
        $pageSize = isset($query['page_size']) && ctype_digit((string) $query['page_size'])
            ? (int) $query['page_size']
            : 25;
        $pageSize = max(1, min(200, $pageSize));

        $q = isset($query['q']) && is_string($query['q']) && trim($query['q']) !== '' ? trim((string) $query['q']) : null;
        if ($q !== null) {
            // SEC_REVIEW F30 / F46: bound `q` to characters that can appear in
            // a literal IP (digits, hex, `:`, `.`) and cap its length. The
            // repository uses `q` as an anchored LIKE prefix; rejecting other
            // shapes here forecloses non-anchored full-table scans and the
            // `%`/`_` wildcard-injection vector in one go. 64 is well above
            // the 39-char IPv6 maximum.
            if (strlen($q) > 64 || preg_match('/^[0-9a-fA-F:.]+$/', $q) !== 1) {
                $errors['q'] = 'must be an IP or IP prefix (digits, hex, `:`, `.`; max 64 chars)';
                $q = null;
            }
        }

        $categoryId = null;
        if (isset($query['category']) && is_string($query['category']) && $query['category'] !== '') {
            $cat = $this->categories->findActiveBySlug($query['category']);
            if ($cat === null) {
                $errors['category'] = 'unknown category slug';
            } else {
                $categoryId = $cat->id;
            }
        }

        $minScore = $this->parseFloat($query['min_score'] ?? null);
        $maxScore = $this->parseFloat($query['max_score'] ?? null);

        $country = null;
        if (isset($query['country']) && is_string($query['country']) && $query['country'] !== '') {
            if (preg_match('/^[A-Za-z]{2}$/', $query['country']) !== 1) {
                $errors['country'] = 'must be a 2-letter ISO code';
            } else {
                $country = strtoupper($query['country']);
            }
        }

        $asn = null;
        if (isset($query['asn']) && (string) $query['asn'] !== '') {
            if (!ctype_digit((string) $query['asn'])) {
                $errors['asn'] = 'must be a positive integer';
            } else {
                $asn = (int) $query['asn'];
            }
        }

        $status = null;
        if (isset($query['status']) && is_string($query['status']) && $query['status'] !== '') {
            if (!in_array($query['status'], ['scored', 'manual', 'allowlisted', 'clean'], true)) {
                $errors['status'] = 'must be scored | manual | allowlisted | clean';
            } else {
                $status = $query['status'];
            }
        }

        return [
            'errors' => $errors,
            'page' => $page,
            'page_size' => $pageSize,
            'q' => $q,
            'category_id' => $categoryId,
            'min_score' => $minScore,
            'max_score' => $maxScore,
            'country' => $country,
            'asn' => $asn,
            'status' => $status,
        ];
    }

    private function parseFloat(mixed $value): ?float
    {
        if ($value === null || $value === '') {
            return null;
        }
        if (is_numeric($value)) {
            return (float) $value;
        }

        return null;
    }

    /**
     * Resolve the row's effective status without an extra DB hit. The
     * search result already contains `max_score`, so we can short-circuit
     * the `hasAnyScore` query that `EffectiveStatusService` would issue
     * per row. Allow / manual-block checks stay O(1) via the in-memory
     * evaluator that's already loaded once for the request.
     */
    private function effectiveStatusFromRow(string $ipBin, float $maxScore, CidrEvaluator $evaluator): EffectiveStatus
    {
        $ip = IpAddress::fromBinary($ipBin);
        if ($evaluator->isAllowlisted($ip)) {
            return EffectiveStatus::Allowlisted;
        }
        if ($evaluator->isManuallyBlocked($ip)) {
            return EffectiveStatus::ManuallyBlocked;
        }
        if ($maxScore > 0) {
            return EffectiveStatus::Scored;
        }

        return EffectiveStatus::Clean;
    }

    /**
     * @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 function formatTimestamp(?string $value): ?string
    {
        if ($value === null) {
            return null;
        }
        // Convert "YYYY-MM-DD HH:MM:SS" → ISO 8601 "YYYY-MM-DDTHH:MM:SSZ".
        $s = trim($value);
        if (str_contains($s, 'T')) {
            return $s;
        }

        return str_replace(' ', 'T', $s) . 'Z';
    }
}
