<?php

declare(strict_types=1);

namespace App\Application\Jobs;

use App\Domain\Jobs\Job;
use App\Domain\Jobs\JobContext;
use App\Domain\Jobs\JobResult;
use App\Domain\Jobs\JobStatus;
use App\Infrastructure\Jobs\JobRunner;
use App\Infrastructure\Jobs\JobRunRepository;
use Closure;
use Throwable;

/**
 * The cron-friendly dispatcher. Iterates the registry and invokes any job
 * whose `defaultIntervalSeconds` has elapsed since its last finished run.
 *
 * Tick is itself a Job so its invocation produces a `job_runs` row with
 * `job_name = 'tick'`. The lock prevents two scheduler ticks racing if a
 * previous tick is still wading through child jobs.
 *
 * Per-job exceptions are caught and recorded — one bad job must not abort
 * the dispatcher.
 */
final class TickJob implements Job
{
    public const NAME = 'tick';

    /**
     * @param Closure(): iterable<string, Job> $jobsResolver Returns the
     *     registered jobs at run time. The closure indirection breaks a
     *     construction-time cycle between TickJob and JobRegistry (the
     *     registry holds tick; tick iterates the registry).
     */
    public function __construct(
        private readonly Closure $jobsResolver,
        private readonly JobRunner $runner,
        private readonly JobRunRepository $runs,
    ) {
    }

    public function name(): string
    {
        return self::NAME;
    }

    public function defaultIntervalSeconds(): int
    {
        // Tick runs by external schedule (cron / sidecar). The dispatcher
        // never asks "is tick due?" against this value — it's surfaced
        // only for /internal/jobs/status display.
        return 60;
    }

    public function maxRuntimeSeconds(): int
    {
        return 300;
    }

    public function run(JobContext $context): JobResult
    {
        $now = $context->clock->now();
        $latest = $this->runs->latestPerJob();

        $invoked = 0;
        $invokedNames = [];
        $jobs = ($this->jobsResolver)();
        foreach ($jobs as $name => $job) {
            if ($name === self::NAME) {
                continue; // never re-enter ourselves
            }

            $row = $latest[$name] ?? null;
            if (!self::isDue($job, $row, $now)) {
                continue;
            }

            try {
                $outcome = $this->runner->run($job, [], 'schedule');
                $invokedNames[] = $name;
                ++$invoked;

                if ($outcome->status === JobStatus::Failure) {
                    $context->logger->warning('tick_child_failed', [
                        'job' => $name,
                        'run_id' => $outcome->runId,
                        'error' => $outcome->errorMessage,
                    ]);
                }
            } catch (Throwable $e) {
                // JobRunner already maps Throwables to JobOutcome::Failure;
                // this catches anything that escapes (eg. lock-repo bug)
                // so one rotten job can't take the tick down.
                $context->logger->error('tick_child_threw', [
                    'job' => $name,
                    'error' => $e->getMessage(),
                ]);
            }
        }

        return new JobResult(
            itemsProcessed: $invoked,
            details: ['invoked' => $invokedNames],
        );
    }

    /**
     * @param array{
     *     id?: int,
     *     job_name?: string,
     *     status?: string,
     *     finished_at?: ?\DateTimeImmutable,
     *     started_at?: ?\DateTimeImmutable
     * }|null $latestRow
     */
    private static function isDue(Job $job, ?array $latestRow, \DateTimeImmutable $now): bool
    {
        if ($latestRow === null) {
            return true;
        }

        $finished = $latestRow['finished_at'] ?? $latestRow['started_at'] ?? null;
        if (!$finished instanceof \DateTimeImmutable) {
            // running row with no finish; conservatively treat as still
            // in flight, don't pile on.
            return false;
        }

        return ($now->getTimestamp() - $finished->getTimestamp()) >= $job->defaultIntervalSeconds();
    }
}
