<?php

declare(strict_types=1);

namespace App\Tests\Integration\Jobs;

use App\Application\Jobs\TickJob;
use App\Domain\Jobs\Job;
use App\Domain\Jobs\JobContext;
use App\Domain\Jobs\JobResult;
use App\Domain\Jobs\JobStatus;
use App\Domain\Time\FixedClock;
use App\Infrastructure\Jobs\JobLockRepository;
use App\Infrastructure\Jobs\JobRunner;
use App\Infrastructure\Jobs\JobRunRepository;
use App\Tests\Integration\Support\AppTestCase;
use Monolog\Handler\NullHandler;
use Monolog\Logger;

/**
 * Tick must invoke only jobs whose interval has elapsed since their last
 * finished run. Two stub jobs with different intervals + a freshly-recorded
 * run for one of them validates the dispatcher's "is due?" logic.
 */
final class TickJobTest extends AppTestCase
{
    public function testInvokesOnlyDueJobs(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $logger = new Logger('test');
        $logger->pushHandler(new NullHandler());

        $hot = new StubJob('hot', 60);    // due every minute
        $cold = new StubJob('cold', 3600); // due every hour
        $never = new StubJob('never-run', 60); // no prior run → always due

        // Pre-write `job_runs` rows: "hot" finished 30 seconds ago (NOT
        // due yet), "cold" finished 30 seconds ago (also not due).
        $this->db->insert('job_runs', [
            'job_name' => 'hot',
            'status' => 'success',
            'items_processed' => 0,
            'triggered_by' => 'manual',
            'started_at' => $clock->now()->modify('-31 seconds')->format('Y-m-d H:i:s'),
            'finished_at' => $clock->now()->modify('-30 seconds')->format('Y-m-d H:i:s'),
        ]);
        $this->db->insert('job_runs', [
            'job_name' => 'cold',
            'status' => 'success',
            'items_processed' => 0,
            'triggered_by' => 'manual',
            'started_at' => $clock->now()->modify('-31 seconds')->format('Y-m-d H:i:s'),
            'finished_at' => $clock->now()->modify('-30 seconds')->format('Y-m-d H:i:s'),
        ]);

        $runner = new JobRunner(
            new JobLockRepository($this->db),
            new JobRunRepository($this->db),
            $clock,
            $logger,
        );

        $tick = new TickJob(
            jobsResolver: static fn (): array => [
                'hot' => $hot,
                'cold' => $cold,
                'never-run' => $never,
            ],
            runner: $runner,
            runs: new JobRunRepository($this->db),
        );

        $result = $tick->run(new JobContext($clock, $logger, []));

        // Only `never-run` should fire — the other two are within their
        // interval window.
        self::assertSame(1, $result->itemsProcessed);
        self::assertSame(['never-run'], $result->details['invoked']);
        self::assertFalse($hot->wasInvoked, 'hot must not have run');
        self::assertFalse($cold->wasInvoked, 'cold must not have run');
        self::assertTrue($never->wasInvoked, 'never-run should have run');
    }

    public function testDueAfterIntervalElapses(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $logger = new Logger('test');
        $logger->pushHandler(new NullHandler());

        $job = new StubJob('schedulable', 60);

        $this->db->insert('job_runs', [
            'job_name' => 'schedulable',
            'status' => 'success',
            'items_processed' => 0,
            'triggered_by' => 'schedule',
            'started_at' => $clock->now()->modify('-2 minutes')->format('Y-m-d H:i:s'),
            'finished_at' => $clock->now()->modify('-2 minutes')->format('Y-m-d H:i:s'),
        ]);

        $runner = new JobRunner(
            new JobLockRepository($this->db),
            new JobRunRepository($this->db),
            $clock,
            $logger,
        );

        $tick = new TickJob(
            jobsResolver: static fn (): array => ['schedulable' => $job],
            runner: $runner,
            runs: new JobRunRepository($this->db),
        );

        $result = $tick->run(new JobContext($clock, $logger, []));
        self::assertSame(1, $result->itemsProcessed);
        self::assertTrue($job->wasInvoked);
    }

    public function testFailingChildJobDoesNotAbortDispatcher(): void
    {
        $clock = FixedClock::at('2026-04-29T00:00:00Z');
        $logger = new Logger('test');
        $logger->pushHandler(new NullHandler());

        $broken = new StubJob('broken', 60, throw: true);
        $healthy = new StubJob('healthy', 60);

        $runner = new JobRunner(
            new JobLockRepository($this->db),
            new JobRunRepository($this->db),
            $clock,
            $logger,
        );

        $tick = new TickJob(
            jobsResolver: static fn (): array => ['broken' => $broken, 'healthy' => $healthy],
            runner: $runner,
            runs: new JobRunRepository($this->db),
        );

        $result = $tick->run(new JobContext($clock, $logger, []));
        // Both due (no prior runs); both invoked.
        self::assertSame(2, $result->itemsProcessed);

        // The broken child's row exists with failure status.
        $rows = $this->db->fetchAllAssociative('SELECT job_name, status FROM job_runs ORDER BY id');
        $brokenRow = array_values(array_filter(
            $rows,
            static fn (array $r): bool => $r['job_name'] === 'broken'
        ))[0];
        self::assertSame(JobStatus::Failure->value, $brokenRow['status']);
    }
}

final class StubJob implements Job
{
    public bool $wasInvoked = false;

    public function __construct(
        private readonly string $name,
        private readonly int $interval,
        private readonly bool $throw = false,
    ) {
    }

    public function name(): string
    {
        return $this->name;
    }

    public function defaultIntervalSeconds(): int
    {
        return $this->interval;
    }

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

    public function run(JobContext $context): JobResult
    {
        $this->wasInvoked = true;
        if ($this->throw) {
            throw new \RuntimeException('stub failure');
        }

        return new JobResult(itemsProcessed: 1);
    }
}
