<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Application\Jobs\RefreshGeoipJob;
use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditEmitter;
use App\Domain\Time\Clock;
use App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader;
use App\Infrastructure\Jobs\JobLockRepository;
use App\Infrastructure\Jobs\JobRegistry;
use App\Infrastructure\Jobs\JobRunner;
use App\Infrastructure\Jobs\JobRunRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Admin-side proxy for the internal jobs surface.
 *
 *  - `GET  /api/v1/admin/jobs/status`  (Viewer): mirrors the data from
 *     `/internal/jobs/status` but is reachable without the internal
 *     token, and never short-circuits 404/401 on RFC1918 boundaries.
 *  - `POST /api/v1/admin/jobs/trigger/{name}`  (Admin): invokes the
 *     same Job class the internal handler does, but with
 *     `triggered_by="manual"`. Emits one `job.triggered` audit row.
 *     refresh-geoip honours the same 412-no-credential short-circuit
 *     the internal handler implements.
 *
 * The split exists so the UI never has to forward through the internal
 * token (which is bound to RFC1918 networks). Per SPEC §6/§7.
 */
final class JobsAdminController
{
    use AdminControllerSupport;

    public function __construct(
        private readonly JobRegistry $registry,
        private readonly JobRunner $runner,
        private readonly JobRunRepository $runs,
        private readonly JobLockRepository $locks,
        private readonly Clock $clock,
        private readonly GeoIpDownloader $geoipDownloader,
        private readonly AuditEmitter $audit,
    ) {
    }

    public function status(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $now = $this->clock->now();
        $latest = $this->runs->latestPerJob();

        $jobs = [];
        foreach ($this->registry->all() as $name => $job) {
            $row = $latest[$name] ?? null;

            $finishedAt = $row['finished_at'] ?? null;
            $overdue = $row === null
                || ($finishedAt instanceof \DateTimeImmutable
                    && ($now->getTimestamp() - $finishedAt->getTimestamp()) > $job->defaultIntervalSeconds());

            $jobs[$name] = [
                'name' => $name,
                'default_interval_seconds' => $job->defaultIntervalSeconds(),
                'max_runtime_seconds' => $job->maxRuntimeSeconds(),
                'overdue' => $overdue,
                'lock' => $this->locks->status($name),
                'last_run' => $row === null ? null : [
                    'id' => $row['id'],
                    'status' => $row['status'],
                    'items_processed' => $row['items_processed'],
                    'triggered_by' => $row['triggered_by'],
                    'started_at' => self::formatTs($row['started_at']),
                    'finished_at' => self::formatTs($row['finished_at']),
                    'error_message' => $row['error_message'],
                ],
            ];
        }

        return self::json($response, 200, [
            'now' => $now->format('Y-m-d\TH:i:s\Z'),
            'jobs' => $jobs,
        ]);
    }

    /**
     * Job names allowed by the controller, regardless of what the
     * registry would return for `has()`. SEC_REVIEW F44: validate the
     * `{name}` path segment against this character class *before* it
     * flows into the registry lookup or the `job.triggered` audit
     * row, so a future refactor that turns `has()` permissive on
     * trim / url-decode / case-folding cannot escalate the route
     * into log injection or forged audit entries.
     */
    private const JOB_NAME_PATTERN = '/^[a-z0-9_-]+$/';

    /**
     * @param array{name: string} $args
     */
    public function trigger(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        $name = $args['name'];
        if (preg_match(self::JOB_NAME_PATTERN, $name) !== 1) {
            return self::error($response, 404, 'unknown_job');
        }
        if (!$this->registry->has($name)) {
            return self::error($response, 404, 'unknown_job');
        }

        // refresh-geoip's credential short-circuit: same 412 envelope the
        // internal handler returns. Don't take the lock or even start the
        // job for opt-in providers without their key.
        if ($name === RefreshGeoipJob::NAME
            && $this->geoipDownloader->requiresCredential()
            && !$this->geoipDownloader->hasCredential()) {
            $missing = match ($this->geoipDownloader->name()) {
                'maxmind' => 'MAXMIND_LICENSE_KEY',
                'ipinfo' => 'IPINFO_TOKEN',
                default => 'CREDENTIAL',
            };

            return self::json($response, 412, [
                'error' => 'no_credential',
                'provider' => $this->geoipDownloader->name(),
                'missing' => $missing,
            ]);
        }

        $body = self::jsonBody($request);
        $params = self::sanitiseParams($body);

        // Audit BEFORE running the job — even if the job fails, we want a
        // record that it was invoked. SEC_REVIEW F4: emitOrThrow so a
        // failed audit insert produces a 500 instead of silently running
        // the job without a trigger row in audit_log.
        $this->audit->emitOrThrow(
            AuditAction::JOB_TRIGGERED,
            'job',
            $name,
            ['name' => $name, 'params' => $params, 'triggered_by' => 'manual'],
            self::auditContext($request),
            $name,
        );

        $job = $this->registry->get($name);
        $outcome = $this->runner->run($job, $params, 'manual');

        $response = $response
            ->withStatus($outcome->httpStatus())
            ->withHeader('Content-Type', 'application/json');
        $response->getBody()->write((string) json_encode($outcome->toArray()));

        return $response;
    }

    /**
     * @param array<string, mixed> $body
     * @return array<string, mixed>
     */
    private static function sanitiseParams(array $body): array
    {
        // Whitelist the same params the internal handler accepts; ignore
        // anything else so a malicious admin can't smuggle config.
        $params = [];
        if (isset($body['full'])) {
            $params['full'] = (bool) $body['full'];
        }
        if (isset($body['max_rows']) && is_numeric($body['max_rows'])) {
            $params['max_rows'] = (int) $body['max_rows'];
        }
        if (isset($body['reenrich'])) {
            $params['reenrich'] = (bool) $body['reenrich'];
        }

        return $params;
    }

    private static function formatTs(mixed $value): ?string
    {
        if (!$value instanceof \DateTimeImmutable) {
            return null;
        }

        return $value->format('Y-m-d\TH:i:s\Z');
    }
}
