<?php

declare(strict_types=1);

namespace App\Tests\Integration\Admin;

use App\Domain\Auth\Role;
use App\Domain\Auth\TokenKind;
use App\Tests\Integration\Support\AppTestCase;

/**
 * `/api/v1/admin/jobs/{status,trigger}` — admin-only wrapper that the UI
 * uses to invoke jobs without needing the internal token.
 */
final class JobsAdminControllerTest extends AppTestCase
{
    public function testStatusRequiresViewer(): void
    {
        $resp = $this->request('GET', '/api/v1/admin/jobs/status');
        self::assertSame(401, $resp->getStatusCode());
    }

    public function testStatusReturnsAllJobs(): void
    {
        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
        $resp = $this->request('GET', '/api/v1/admin/jobs/status', ['Authorization' => 'Bearer ' . $token]);
        self::assertSame(200, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertArrayHasKey('jobs', $body);
        foreach (['recompute-scores', 'cleanup-audit', 'enrich-pending', 'refresh-geoip', 'tick'] as $name) {
            self::assertArrayHasKey($name, $body['jobs']);
        }
    }

    public function testTriggerOperatorForbidden(): void
    {
        $token = $this->createToken(TokenKind::Admin, Role::Operator);
        $resp = $this->request(
            'POST',
            '/api/v1/admin/jobs/trigger/recompute-scores',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(403, $resp->getStatusCode());
    }

    public function testTriggerUnknownJobReturns404(): void
    {
        $token = $this->createToken(TokenKind::Admin, Role::Admin);
        $resp = $this->request(
            'POST',
            '/api/v1/admin/jobs/trigger/does-not-exist',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(404, $resp->getStatusCode());
    }

    /**
     * @return iterable<string, array{string}>
     */
    public static function malformedJobNames(): iterable
    {
        // SEC_REVIEW F44: any name outside `^[a-z0-9_-]+$` must 404
        // BEFORE flowing into `registry->has()` or the `job.triggered`
        // audit row. The Slim default segment regex `[^/]+` only
        // protects against `/`; everything else (uppercase, dots,
        // spaces, control chars, brackets) needs the controller gate.
        yield 'uppercase' => ['Recompute-Scores'];
        yield 'dotted' => ['recompute.scores'];
        yield 'space' => ['recompute scores'];
        yield 'newline injection' => ["recompute\nfaked"];
        yield 'cr injection' => ["recompute\rfaked"];
        yield 'bracket' => ['recompute[scores]'];
        yield 'percent' => ['recompute%20scores'];
        yield 'empty after url decode' => ['..'];
    }

    #[\PHPUnit\Framework\Attributes\DataProvider('malformedJobNames')]
    public function testTriggerRejectsMalformedJobName(string $name): void
    {
        $token = $this->createToken(TokenKind::Admin, Role::Admin);
        $resp = $this->request(
            'POST',
            '/api/v1/admin/jobs/trigger/' . rawurlencode($name),
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(404, $resp->getStatusCode(), "expected 404 for malformed name '{$name}'");
        // Critically: no audit row is emitted for the malformed name.
        $audit = $this->db->fetchOne(
            "SELECT COUNT(*) FROM audit_log WHERE action = 'job.triggered'"
        );
        self::assertSame(0, (int) $audit, "no audit row for malformed name '{$name}'");
    }

    public function testTriggerRecomputeRunsAndAuditsAsManual(): void
    {
        $token = $this->createToken(TokenKind::Admin, Role::Admin);
        $resp = $this->request(
            'POST',
            '/api/v1/admin/jobs/trigger/recompute-scores',
            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
            '{}',
        );
        self::assertSame(200, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertSame('recompute-scores', $body['job']);
        self::assertSame('success', $body['status']);

        // job_runs.triggered_by = manual
        $row = $this->db->fetchAssociative(
            "SELECT triggered_by FROM job_runs WHERE job_name = 'recompute-scores' ORDER BY id DESC LIMIT 1"
        );
        self::assertSame('manual', $row['triggered_by']);

        // Audit row attributed to the admin token
        $audit = $this->db->fetchAssociative(
            "SELECT actor_kind, action, target_id, details_json FROM audit_log WHERE action = 'job.triggered' ORDER BY id DESC LIMIT 1"
        );
        self::assertIsArray($audit);
        self::assertSame('admin-token', $audit['actor_kind']);
        self::assertSame('recompute-scores', $audit['target_id']);
        $details = json_decode((string) $audit['details_json'], true);
        self::assertSame('manual', $details['triggered_by']);
    }

    public function testRefreshGeoip412UnderMaxmindWithoutKey(): void
    {
        // Swap the downloader binding to MaxMind without a key.
        if (method_exists($this->container, 'set')) {
            /** @var \DI\Container $c */
            $c = $this->container;
            $c->set(
                \App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader::class,
                new \App\Infrastructure\Enrichment\Downloaders\MaxMindDownloader(new \GuzzleHttp\Client(), licenseKey: ''),
            );
            $c->set(
                \App\Application\Admin\JobsAdminController::class,
                $c->make(\App\Application\Admin\JobsAdminController::class),
            );
            $this->app = \App\App\AppFactory::build($this->container);
        }

        $token = $this->createToken(TokenKind::Admin, Role::Admin);
        $resp = $this->request(
            'POST',
            '/api/v1/admin/jobs/trigger/refresh-geoip',
            ['Authorization' => 'Bearer ' . $token],
        );
        self::assertSame(412, $resp->getStatusCode());
        $body = $this->decode($resp);
        self::assertSame('no_credential', $body['error']);
        self::assertSame('maxmind', $body['provider']);
        self::assertSame('MAXMIND_LICENSE_KEY', $body['missing']);
    }
}
