<?php

declare(strict_types=1);

namespace App\Tests\Integration\Crud;

use App\Auth\UserContext;
use App\Http\CsrfMiddleware;
use App\Tests\Integration\Support\AppTestCase;

/**
 * SPEC §M10 acceptance: every list page renders and at least one
 * happy-path + one validation-error path per resource is covered.
 *
 * Each test queues responses for the AdminClient calls the controller
 * fires; the AppTestCase's MockHandler returns them in FIFO order.
 */
final class CrudPagesTest extends AppTestCase
{
    protected function setUp(): void
    {
        $this->bootApp();
        $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
        $_SESSION['_last_active'] = time();
        $_SESSION['_authenticated_at'] = time();
    }

    // ---- list pages render ----

    public function testManualBlocksListRenders(): void
    {
        $this->enqueueApiResponse(200, ['items' => [
            ['id' => 1, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'edge', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null],
        ], 'total' => 1]);

        $response = $this->request('GET', '/app/manual-blocks');
        self::assertSame(200, $response->getStatusCode());
        $body = (string) $response->getBody();
        self::assertStringContainsString('192.0.2.0/24', $body);
        self::assertStringContainsString('Manual blocks', $body);
    }

    public function testSubnetsAliasFiltersToSubnets(): void
    {
        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);

        $response = $this->request('GET', '/app/subnets');
        self::assertSame(200, $response->getStatusCode());
        self::assertStringContainsString('Subnets', (string) $response->getBody());
    }

    public function testAllowlistListRenders(): void
    {
        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);

        $response = $this->request('GET', '/app/allowlist');
        self::assertSame(200, $response->getStatusCode());
        self::assertStringContainsString('Allowlist', (string) $response->getBody());
    }

    public function testPoliciesListRenders(): void
    {
        $this->enqueueApiResponse(200, ['items' => [
            ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
        ], 'total' => 1]);

        $response = $this->request('GET', '/app/policies');
        self::assertSame(200, $response->getStatusCode());
        self::assertStringContainsString('moderate', (string) $response->getBody());
    }

    public function testPolicyEditAlignsThresholdsToCategoryRows(): void
    {
        // Regression: |merge of {(int_id): val} renumbered the keys via
        // PHP array_merge, shifting each rendered value up one row and
        // leaving the last category blank. Use category IDs that are not
        // a 0-based sequence so any reindexing is observable.
        $this->enqueueApiResponse(200, [
            'id' => 1, 'name' => 'custom', 'description' => null,
            'include_manual_blocks' => true,
            'thresholds' => [
                ['category_id' => 11, 'category_slug' => 'scanners', 'threshold' => 40.0],
                ['category_id' => 12, 'category_slug' => 'indexer', 'threshold' => 30.0],
                ['category_id' => 13, 'category_slug' => 'malicious', 'threshold' => 20.0],
                ['category_id' => 14, 'category_slug' => 'load', 'threshold' => 10.0],
            ],
            'created_at' => '2026-04-29T10:00:00Z',
        ]);
        $this->enqueueApiResponse(200, ['items' => [
            ['id' => 11, 'slug' => 'scanners', 'name' => 'Scanners', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
            ['id' => 12, 'slug' => 'indexer', 'name' => 'Indexer', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
            ['id' => 13, 'slug' => 'malicious', 'name' => 'Malicious', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
            ['id' => 14, 'slug' => 'load', 'name' => 'Load', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
        ], 'total' => 4]);

        $response = $this->request('GET', '/app/policies/1');
        self::assertSame(200, $response->getStatusCode());
        $body = (string) $response->getBody();

        foreach ([
            ['scanners', '40'],
            ['indexer', '30'],
            ['malicious', '20'],
            ['load', '10'],
        ] as [$slug, $val]) {
            self::assertMatchesRegularExpression(
                '/name="thresholds\[' . preg_quote($slug, '/') . '\]"[^>]*value="' . $val . '"/',
                $body,
                "row {$slug} must render value {$val}",
            );
        }
    }

    public function testReportersListRenders(): void
    {
        $this->enqueueApiResponse(200, ['data' => [
            ['id' => 1, 'name' => 'web-prod-01', 'description' => 'edge', 'trust_weight' => 1.0, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z'],
        ], 'total' => 1, 'page' => 1, 'limit' => 200]);

        $response = $this->request('GET', '/app/reporters');
        self::assertSame(200, $response->getStatusCode());
        self::assertStringContainsString('web-prod-01', (string) $response->getBody());
    }

    public function testConsumersListRenders(): void
    {
        // First call: listConsumers; second: listPolicies (for the dropdown).
        $this->enqueueApiResponse(200, ['data' => [
            ['id' => 1, 'name' => 'fw-1', 'policy_id' => 5, 'description' => null, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z', 'last_pulled_at' => null],
        ], 'total' => 1, 'page' => 1, 'limit' => 200]);
        $this->enqueueApiResponse(200, ['items' => [
            ['id' => 5, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
        ], 'total' => 1]);

        $response = $this->request('GET', '/app/consumers');
        self::assertSame(200, $response->getStatusCode());
        $body = (string) $response->getBody();
        self::assertStringContainsString('fw-1', $body);
        self::assertStringContainsString('moderate', $body);
    }

    public function testTokensListRenders(): void
    {
        // listTokens, listReporters, listConsumers in that order.
        $this->enqueueApiResponse(200, ['data' => [
            ['id' => 1, 'kind' => 'admin', 'token_prefix' => 'irdb_adm', 'role' => 'viewer', 'reporter_id' => null, 'consumer_id' => null, 'expires_at' => null, 'revoked_at' => null, 'last_used_at' => null, 'created_at' => '2026-04-29T10:00:00Z'],
        ], 'total' => 1, 'page' => 1, 'limit' => 200]);
        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);

        $response = $this->request('GET', '/app/tokens');
        self::assertSame(200, $response->getStatusCode());
        self::assertStringContainsString('irdb_adm', (string) $response->getBody());
    }

    public function testCategoriesListRenders(): void
    {
        $this->enqueueApiResponse(200, ['items' => [
            ['id' => 1, 'slug' => 'brute_force', 'name' => 'Brute force', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
        ], 'total' => 1]);

        $response = $this->request('GET', '/app/categories');
        self::assertSame(200, $response->getStatusCode());
        self::assertStringContainsString('brute_force', (string) $response->getBody());
    }

    // ---- happy + validation paths per resource ----

    public function testManualBlockCreateHappyPath(): void
    {
        $this->enqueueApiResponse(201, ['id' => 99, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24']);
        $token = $this->csrfFromManualBlocks();

        $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'test']);
        $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/app/manual-blocks', $response->getHeaderLine('Location'));
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertSame('success', $flash[0]['type']);
    }

    public function testManualBlockCreateExpandsBareDateToEndOfDay(): void
    {
        // The form uses <input type="date">, which submits "YYYY-MM-DD".
        // The controller must expand that to T23:59:59Z so the block
        // covers the full selected calendar day.
        $this->enqueueApiResponse(201, ['id' => 100, 'kind' => 'ip', 'ip' => '203.0.113.5']);
        $token = $this->csrfFromManualBlocks();

        $body = http_build_query([
            'csrf_token' => $token,
            'kind' => 'ip',
            'ip' => '203.0.113.5',
            'reason' => 'eod test',
            'expires_at' => '2026-12-31',
        ]);
        $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
        self::assertSame(303, $response->getStatusCode());

        // The last captured outgoing call is the POST to the api;
        // earlier history entries are the GETs from csrfFromManualBlocks.
        $last = end($this->apiHistory);
        self::assertNotFalse($last);
        self::assertSame('POST', $last['request']->getMethod());
        $payload = json_decode((string) $last['request']->getBody(), true);
        self::assertIsArray($payload);
        self::assertSame('2026-12-31T23:59:59Z', $payload['expires_at']);
    }

    public function testManualBlockCreateValidationErrorFlashesField(): void
    {
        // The MockHandler is FIFO: the GET inside csrfFromManualBlocks
        // would consume any earlier-queued response, so queue the 400
        // AFTER the helper's GET has been served.
        $token = $this->csrfFromManualBlocks();
        $this->enqueueApiResponse(400, [
            'error' => 'validation_failed',
            'details' => ['cidr' => 'invalid format'],
        ]);

        $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => 'not-a-cidr']);
        $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertSame('error', $flash[0]['type']);
        self::assertStringContainsString('cidr', $flash[0]['message']);
    }

    public function testTokenCreateStashesRawTokenForOneTimeDisplay(): void
    {
        // GET /app/tokens warm-up to set CSRF + session.
        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
        $this->request('GET', '/app/tokens');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
        self::assertNotEmpty($token);

        // POST creates a new admin token.
        $this->enqueueApiResponse(201, [
            'id' => 42, 'kind' => 'admin', 'token_prefix' => 'irdb_adm',
            'raw_token' => 'irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
            'role' => 'viewer',
        ]);
        $body = http_build_query(['csrf_token' => $token, 'kind' => 'admin', 'role' => 'viewer']);
        $createResp = $this->request('POST', '/app/tokens', [], $body, 'application/x-www-form-urlencoded');
        self::assertSame(303, $createResp->getStatusCode());

        // The follow-up GET surfaces the raw token in the response body
        // and clears the session slot afterwards.
        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
        $listResp = $this->request('GET', '/app/tokens');

        $html = (string) $listResp->getBody();
        self::assertStringContainsString('irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $html);
        self::assertArrayNotHasKey('_token_just_created', $_SESSION);
    }

    public function testCategoryCreateValidationFlash(): void
    {
        // Warm-up GET for CSRF token.
        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
        $this->request('GET', '/app/categories');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $this->enqueueApiResponse(400, [
            'error' => 'validation_failed',
            'details' => ['slug' => 'already exists'],
        ]);
        $body = http_build_query([
            'csrf_token' => $token,
            'slug' => 'brute_force',
            'name' => 'Dup',
            'decay_function' => 'exponential',
            'decay_param' => '14',
        ]);
        $response = $this->request('POST', '/app/categories', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/app/categories', $response->getHeaderLine('Location'));
        $flash = $_SESSION['_flash'] ?? [];
        self::assertNotEmpty($flash);
        self::assertStringContainsString('slug', $flash[0]['message']);
    }

    public function testPolicyDeleteForwardsToApi(): void
    {
        $this->enqueueApiResponse(200, ['items' => [
            ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
        ], 'total' => 1]);
        $this->request('GET', '/app/policies');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $this->enqueueApiResponse(204, []);
        $body = http_build_query(['csrf_token' => $token]);
        $response = $this->request('POST', '/app/policies/1/delete', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/app/policies', $response->getHeaderLine('Location'));
    }

    public function testReporterCreateHappyPath(): void
    {
        $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
        $this->request('GET', '/app/reporters');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');

        $this->enqueueApiResponse(201, ['id' => 17, 'name' => 'test-rep', 'trust_weight' => 1.0, 'is_active' => true]);
        $body = http_build_query(['csrf_token' => $token, 'name' => 'test-rep', 'trust_weight' => '1.0']);
        $response = $this->request('POST', '/app/reporters', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/app/reporters/17', $response->getHeaderLine('Location'));
    }

    public function testIpDetailRendersActionButtonsForOperator(): void
    {
        $_SESSION['_user'] = (new UserContext(1, 'Op', 'operator', null, UserContext::SOURCE_LOCAL))->toArray();
        $this->enqueueApiResponse(200, [
            'ip' => '203.0.113.10',
            'is_ipv4' => true,
            'status' => 'scored',
            'scores' => [],
            'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
            'manual_block' => null,
            'allowlist' => null,
            'history' => [],
            'has_more' => false,
        ]);
        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);

        $response = $this->request('GET', '/app/ips/203.0.113.10');
        $body = (string) $response->getBody();
        self::assertStringContainsString('Add to allowlist', $body);
        self::assertStringContainsString('Manually block', $body);
    }

    public function testIpDetailHidesActionButtonsForViewer(): void
    {
        $_SESSION['_user'] = (new UserContext(2, 'View', 'viewer', null, UserContext::SOURCE_LOCAL))->toArray();
        $this->enqueueApiResponse(200, [
            'ip' => '203.0.113.10',
            'is_ipv4' => true,
            'status' => 'scored',
            'scores' => [],
            'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
            'manual_block' => null,
            'allowlist' => null,
            'history' => [],
            'has_more' => false,
        ]);
        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);

        $response = $this->request('GET', '/app/ips/203.0.113.10');
        $body = (string) $response->getBody();
        self::assertStringNotContainsString('Add to allowlist', $body);
        self::assertStringNotContainsString('Manually block', $body);
    }

    public function testManualBlockDeleteRejectsOpenRedirectInNext(): void
    {
        // SEC_REVIEW F10: an authenticated operator tricked into submitting
        // a forged form (or hit by a CSRF / sub-XSS auto-submit) MUST NOT
        // be redirected off-origin via the `next` field. The controller
        // routes the value through SessionManager::safeNextOrDefault, so
        // any non-local-path next falls back to the resource list.
        $token = $this->csrfFromManualBlocks();
        $this->enqueueApiResponse(204, []);

        $body = http_build_query([
            'csrf_token' => $token,
            'next' => '//evil.example.com/phish',
        ]);
        $response = $this->request('POST', '/app/manual-blocks/42/delete', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/app/manual-blocks', $response->getHeaderLine('Location'));
    }

    public function testManualBlockDeleteHonoursSafeNext(): void
    {
        // Sanity: a legitimate same-origin next (the in-app pattern, e.g.
        // returning to an IP detail page) is preserved.
        $token = $this->csrfFromManualBlocks();
        $this->enqueueApiResponse(204, []);

        $body = http_build_query([
            'csrf_token' => $token,
            'next' => '/app/ips/203.0.113.10',
        ]);
        $response = $this->request('POST', '/app/manual-blocks/42/delete', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/app/ips/203.0.113.10', $response->getHeaderLine('Location'));
    }

    public function testAllowlistDeleteRejectsOpenRedirectInNext(): void
    {
        // SEC_REVIEW F10 mirror on the allowlist controller.
        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
        $this->request('GET', '/app/allowlist');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
        self::assertNotEmpty($token);

        $this->enqueueApiResponse(204, []);
        $body = http_build_query([
            'csrf_token' => $token,
            'next' => 'https://evil.example.com/phish',
        ]);
        $response = $this->request('POST', '/app/allowlist/7/delete', [], $body, 'application/x-www-form-urlencoded');

        self::assertSame(303, $response->getStatusCode());
        self::assertSame('/app/allowlist', $response->getHeaderLine('Location'));
    }

    private function csrfFromManualBlocks(): string
    {
        // GET to set the CSRF token in the session.
        $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
        $this->request('GET', '/app/manual-blocks');
        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
        self::assertNotEmpty($token);

        return $token;
    }
}
