<?php

declare(strict_types=1);

namespace App\Tests\Integration\Admin;

use App\Domain\Auth\Role;
use App\Domain\Auth\TokenKind;
use App\Domain\Ip\IpAddress;
use App\Tests\Integration\Support\AppTestCase;
use Doctrine\DBAL\ParameterType;

/**
 * Covers SPEC §M09.1: admin IP search + detail. Seeds ip_scores +
 * reports + manual_blocks + allowlist directly to drive the controller.
 */
final class IpsControllerTest extends AppTestCase
{
    public function testSearchListsScoredIps(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $this->seedScored('203.0.113.10', 'brute_force', 1.5);
        $this->seedScored('203.0.113.11', 'brute_force', 0.5);

        $response = $this->request('GET', '/api/v1/admin/ips', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(200, $response->getStatusCode());
        $body = $this->decode($response);
        self::assertSame(2, $body['total']);
        $ips = array_map(static fn (array $r): string => $r['ip'], $body['items']);
        self::assertContains('203.0.113.10', $ips);
        self::assertContains('203.0.113.11', $ips);
    }

    public function testSearchSortsByMaxScoreDescending(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $this->seedScored('203.0.113.10', 'brute_force', 0.5);
        $this->seedScored('203.0.113.11', 'brute_force', 5.0);

        $response = $this->request('GET', '/api/v1/admin/ips', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        $body = $this->decode($response);
        self::assertSame('203.0.113.11', $body['items'][0]['ip']);
        self::assertSame('203.0.113.10', $body['items'][1]['ip']);
    }

    public function testSearchFiltersByPrefix(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $this->seedScored('203.0.113.10', 'brute_force', 1.0);
        $this->seedScored('198.51.100.5', 'brute_force', 1.0);
        $this->seedScored('2001:db8::1', 'brute_force', 1.0);

        $response = $this->request('GET', '/api/v1/admin/ips?q=2001', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        $body = $this->decode($response);
        self::assertSame(1, $body['total']);
        self::assertSame('2001:db8::1', $body['items'][0]['ip']);
        self::assertFalse($body['items'][0]['is_ipv4']);
    }

    public function testSearchFiltersByCategory(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $this->seedScored('203.0.113.10', 'brute_force', 1.0);
        $this->seedScored('203.0.113.11', 'spam', 1.0);

        $response = $this->request('GET', '/api/v1/admin/ips?category=spam', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        $body = $this->decode($response);
        self::assertSame(1, $body['total']);
        self::assertSame('203.0.113.11', $body['items'][0]['ip']);
    }

    public function testSearchPaginates(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        for ($i = 0; $i < 5; ++$i) {
            $this->seedScored(sprintf('203.0.113.%d', 10 + $i), 'brute_force', 1.0 + ($i / 10));
        }

        $page1 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=1&page_size=2', [
            'Authorization' => 'Bearer ' . $token,
        ]));
        $page2 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=2&page_size=2', [
            'Authorization' => 'Bearer ' . $token,
        ]));

        self::assertSame(5, $page1['total']);
        self::assertCount(2, $page1['items']);
        self::assertCount(2, $page2['items']);
        self::assertNotSame($page1['items'][0]['ip'], $page2['items'][0]['ip']);
    }

    public function testSearchRejectsUnknownCategory(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $response = $this->request('GET', '/api/v1/admin/ips?category=bogus', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(400, $response->getStatusCode());
    }

    /**
     * SEC_REVIEW F30: the substring fallback (`%q%`) used to drive a
     * full-table scan on `ip_scores`. The controller now rejects any `q`
     * that isn't IP-shaped so the repo only ever runs an anchored
     * `LIKE 'q%'` prefix scan.
     */
    public function testSearchRejectsNonIpShapedQuery(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        // Each of these would previously have hit the `%q%` substring path.
        $bad = ['foo', '203.0.113.10 OR 1=1', '%X%', 'g00d', '203.0.113.10%', '_____'];
        foreach ($bad as $q) {
            $response = $this->request('GET', '/api/v1/admin/ips?q=' . rawurlencode($q), [
                'Authorization' => 'Bearer ' . $token,
            ]);
            self::assertSame(400, $response->getStatusCode(), "expected 400 for q={$q}");
            $body = $this->decode($response);
            self::assertSame('validation_failed', $body['error']);
            self::assertArrayHasKey('q', $body['details']);
        }
    }

    /**
     * SEC_REVIEW F30: cap on `q` length so even an IP-shaped value cannot
     * push a multi-megabyte LIKE parameter through the repository.
     */
    public function testSearchRejectsOverlongQuery(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $tooLong = str_repeat('1', 65); // 64 is the cap.
        $response = $this->request('GET', '/api/v1/admin/ips?q=' . $tooLong, [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(400, $response->getStatusCode());
        $body = $this->decode($response);
        self::assertArrayHasKey('q', $body['details']);
    }

    /**
     * SEC_REVIEW F30: an IP-shaped `q` is still treated as an anchored
     * prefix — the substring path is gone, so a value that previously
     * matched mid-string no longer matches at all.
     */
    public function testSearchQueryIsPrefixAnchoredNotSubstring(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $this->seedScored('203.0.113.10', 'brute_force', 1.0);
        $this->seedScored('198.51.100.5', 'brute_force', 1.0);

        // "113" is a substring of 203.0.113.10 but not a prefix; with the
        // substring path removed the result is empty.
        $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=113', [
            'Authorization' => 'Bearer ' . $token,
        ]));
        self::assertSame(0, $body['total']);

        // The legitimate prefix still matches.
        $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=203.0.113', [
            'Authorization' => 'Bearer ' . $token,
        ]));
        self::assertSame(1, $body['total']);
        self::assertSame('203.0.113.10', $body['items'][0]['ip']);
    }

    /**
     * SEC_REVIEW F32: the list endpoint now batches enrichment, top
     * category, and effective-status lookups so the per-row data surfaces
     * correctly without an extra DB round-trip per IP. Seed enough rows
     * that the previous N+1 loop would have made hundreds of round-trips.
     */
    public function testSearchBatchesPerRowLookups(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);

        // 25 scored IPs, of which two also carry enrichment rows. The
        // assertion checks that the batch lookup correctly attaches
        // enrichment to exactly those IPs and nulls for the rest.
        for ($i = 0; $i < 25; ++$i) {
            $this->seedScored(sprintf('203.0.113.%d', 10 + $i), 'brute_force', 1.0 + ($i / 100));
        }
        $this->seedEnrichment('203.0.113.12', 'AA', 64500, 'AS-AA');
        $this->seedEnrichment('203.0.113.20', 'BB', 64501, 'AS-BB');

        $body = $this->decode($this->request('GET', '/api/v1/admin/ips?page=1&page_size=200', [
            'Authorization' => 'Bearer ' . $token,
        ]));
        self::assertSame(25, $body['total']);

        $byIp = [];
        foreach ($body['items'] as $row) {
            $byIp[$row['ip']] = $row;
        }

        self::assertSame('AA', $byIp['203.0.113.12']['enrichment']['country_code']);
        self::assertSame(64500, $byIp['203.0.113.12']['enrichment']['asn']);
        self::assertSame('BB', $byIp['203.0.113.20']['enrichment']['country_code']);
        self::assertNull($byIp['203.0.113.10']['enrichment']);

        // top_category resolves via the batched score lookup.
        self::assertSame('brute_force', $byIp['203.0.113.10']['top_category']);
        self::assertSame('brute_force', $byIp['203.0.113.20']['top_category']);

        // Status falls out of `max_score > 0` without a per-row hasAnyScore.
        foreach ($body['items'] as $row) {
            self::assertSame('scored', $row['status']);
        }
    }

    /**
     * SEC_REVIEW F32: when an IP appears in the search results but its
     * `max_score` is 0 (e.g. score row exists but has decayed), the row
     * resolves to `clean` without a per-row `hasAnyScore` query.
     */
    public function testSearchStatusUsesMaxScoreColumn(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $this->seedScored('203.0.113.10', 'brute_force', 0.0);

        $body = $this->decode($this->request('GET', '/api/v1/admin/ips', [
            'Authorization' => 'Bearer ' . $token,
        ]));
        self::assertSame(1, $body['total']);
        self::assertSame('203.0.113.10', $body['items'][0]['ip']);
        self::assertSame('clean', $body['items'][0]['status']);
        self::assertNull($body['items'][0]['top_category']);
    }

    public function testDetailReturnsScoresAndStatus(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $reporterId = $this->createReporter('rep-detail');
        $this->seedScored('203.0.113.42', 'brute_force', 2.0);
        $this->seedReport('203.0.113.42', 'brute_force', $reporterId);

        $response = $this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(200, $response->getStatusCode());
        $body = $this->decode($response);
        self::assertSame('203.0.113.42', $body['ip']);
        self::assertTrue($body['is_ipv4']);
        self::assertSame('scored', $body['status']);
        self::assertNotEmpty($body['scores']);
        self::assertSame('brute_force', $body['scores'][0]['category']);
        self::assertNotEmpty($body['history']);
        self::assertSame('report', $body['history'][0]['type']);
    }

    public function testDetailIncludesEnrichmentNullByDefault(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $this->seedScored('203.0.113.42', 'brute_force', 2.0);

        $body = $this->decode($this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
            'Authorization' => 'Bearer ' . $token,
        ]));
        self::assertNull($body['enrichment']['country_code']);
        self::assertNull($body['enrichment']['asn']);
    }

    public function testDetail404OnInvalidIp(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $response = $this->request('GET', '/api/v1/admin/ips/not-an-ip', [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(404, $response->getStatusCode());
    }

    /**
     * @return iterable<string, array{string}>
     */
    public static function nonIpShapedPaths(): iterable
    {
        // SEC_REVIEW F43: the {ip} route now uses a strict
        // `[0-9a-fA-F.:%]+` charset. Anything outside that — slashes,
        // dots-and-slashes traversal, query separators, spaces, dashes —
        // 404s at the route layer before `$args['ip']` is read by the
        // handler. Even though `IpAddress::fromString` would also
        // reject these inputs today, the tightened route protects
        // any future code that uses `$args['ip']` as a filename, log
        // key, or downstream URL component.
        yield 'path traversal' => ['/api/v1/admin/ips/..%2Fetc%2Fpasswd'];
        yield 'with subpath' => ['/api/v1/admin/ips/192.0.2.1/extra'];
        yield 'with query injection' => ['/api/v1/admin/ips/?injected'];
        yield 'with backslash' => ['/api/v1/admin/ips/192.0.2.1\\admin'];
        yield 'with space' => ['/api/v1/admin/ips/192.0.2.1 admin'];
        yield 'with dash' => ['/api/v1/admin/ips/not-an-ip'];
        yield 'with brackets' => ['/api/v1/admin/ips/[2001:db8::1]'];
        // SEC_REVIEW F72: oversized path. Even within the strict
        // charset, an 81-char string isn't a real IP. The route
        // length cap kicks in BEFORE `rawurldecode` runs on the
        // value — defends against the multi-megabyte case the
        // SEC_REVIEW called out.
        yield 'oversized digits' => ['/api/v1/admin/ips/' . str_repeat('1', 81)];
        yield 'oversized hex' => ['/api/v1/admin/ips/' . str_repeat('a', 200)];
    }

    #[\PHPUnit\Framework\Attributes\DataProvider('nonIpShapedPaths')]
    public function testDetailRejectsNonIpShapedPaths(string $path): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $response = $this->request('GET', $path, [
            'Authorization' => 'Bearer ' . $token,
        ]);
        self::assertSame(404, $response->getStatusCode(), "expected 404 for {$path}");
    }

    public function testDetailRendersForUnknownIpWithCleanStatus(): void
    {
        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
        $body = $this->decode($this->request('GET', '/api/v1/admin/ips/198.51.100.99', [
            'Authorization' => 'Bearer ' . $token,
        ]));
        self::assertSame('clean', $body['status']);
        self::assertSame([], $body['scores']);
        self::assertSame([], $body['history']);
    }

    private function seedScored(string $ip, string $categorySlug, float $score): void
    {
        $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
        $ipObj = IpAddress::fromString($ip);
        $stmt = $this->db->prepare(
            'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
            . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
        );
        $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
        $stmt->bindValue('t', $ipObj->text());
        $stmt->bindValue('c', $catId, ParameterType::INTEGER);
        $stmt->bindValue('s', number_format($score, 4, '.', ''));
        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
        $stmt->executeStatement();
    }

    private function seedEnrichment(string $ip, string $countryCode, int $asn, string $asOrg): void
    {
        $ipObj = IpAddress::fromString($ip);
        $stmt = $this->db->prepare(
            'INSERT INTO ip_enrichment (ip_bin, country_code, asn, as_org, enriched_at) '
            . 'VALUES (:b, :c, :a, :o, :now)'
        );
        $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
        $stmt->bindValue('c', $countryCode);
        $stmt->bindValue('a', $asn, ParameterType::INTEGER);
        $stmt->bindValue('o', $asOrg);
        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
        $stmt->executeStatement();
    }

    private function seedReport(string $ip, string $categorySlug, int $reporterId): void
    {
        $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
        $ipObj = IpAddress::fromString($ip);
        $stmt = $this->db->prepare(
            'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at, metadata_json) '
            . 'VALUES (:b, :t, :c, :r, :w, :now, NULL)'
        );
        $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
        $stmt->bindValue('t', $ipObj->text());
        $stmt->bindValue('c', $catId, ParameterType::INTEGER);
        $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
        $stmt->bindValue('w', '1.00');
        $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
        $stmt->executeStatement();
    }
}
