<?php

declare(strict_types=1);

namespace Apexl\Io\Jobs\Tests;

use Apexl\Io\Jobs\Entities\QueueJob;
use Apexl\Io\Jobs\Interfaces\QueueTaskHandlerInterface;
use Apexl\Io\Jobs\Interfaces\SkippableTaskHandlerInterface;
use Apexl\Io\TestInfrastructure\FeatureTest;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use Throwable;

/**
 * A handler that knows it cannot run yet used to have only one way of saying so: throw.
 * That cost four writes per job — lock, attempts, run_after, unlock — and produced an
 * error line describing something entirely intended. A handler holding a backlog back on
 * purpose therefore spent most of the queue's capacity refusing to work, and buried the
 * real errors while it did.
 *
 * shouldRun() is asked before the lock, so declining costs one write and records no
 * attempt. The deferral is not optional: a skipped job left due would occupy a slot in
 * every future batch and starve the handlers behind it.
 */
final class SkippableTaskHandlerTest extends FeatureTest
{
    private function job(string $handler, int $runAfter = 0): QueueJob
    {
        // emptyHandler() is protected precisely so tests can stub it (see QueueJob). Going
        // through System::makeRegisteredService here would exercise a deprecated resolution
        // path and add nothing: what is under test is the instanceof branch and what defer()
        // writes, not how the container finds a class.
        $job = new StubHandlerQueueJob();
        $job->sequence = random_int(1, 2_000_000_000);
        $job->handler = $handler;
        $job->created = new DateTimeImmutable();
        $job->contents = ['nothing' => 'much'];
        $job->locked = false;
        $job->attempts = 0;
        $job->run_after = $runAfter;
        $job->store();

        return $job;
    }

    private function reload(QueueJob $job): QueueJob
    {
        return StubHandlerQueueJob::fromPrimaryKey($job->id);
    }

    #[Test]
    public function aHandlerThatDeclinesIsDeferredWithoutBeingRun(): void
    {
        $job = $this->job(NotReadyHandler::class);

        self::assertFalse($job->shouldRun());

        $job->defer($job->skipDelaySeconds());

        $deferred = $this->reload($job);

        self::assertGreaterThan(time(), $deferred->run_after, 'It must drop out of the finder.');
        self::assertSame(0, $deferred->attempts, 'Nothing was attempted, so nothing is counted.');
        self::assertNull($deferred->failed);
        self::assertFalse($deferred->locked);
    }

    /**
     * The budget matters: a handler capping attempts would otherwise exhaust it by being
     * repeatedly told to wait, and fail a job that never once ran.
     */
    #[Test]
    public function deferringDoesNotSpendAnAttempt(): void
    {
        $job = $this->job(NotReadyHandler::class);

        $job->defer(60);
        $job->defer(60);
        $job->defer(60);

        self::assertSame(0, $this->reload($job)->attempts);
    }

    #[Test]
    public function aHandlerWithoutTheInterfaceAlwaysRuns(): void
    {
        $job = $this->job(OrdinaryHandler::class);

        self::assertTrue($job->shouldRun(), 'The behaviour everything had before this existed.');
        self::assertSame(0, $job->skipDelaySeconds());
    }

    #[Test]
    public function aReadyHandlerRuns(): void
    {
        self::assertTrue($this->job(ReadyHandler::class)->shouldRun());
    }
}

class StubHandlerQueueJob extends QueueJob
{
    protected function emptyHandler(): QueueTaskHandlerInterface
    {
        $class = $this->handler;

        return new $class();
    }
}

class OrdinaryHandler implements QueueTaskHandlerInterface
{
    public function __construct(private readonly mixed $data = null) {}

    public function run(): void {}

    public function handleFailure(string $task, Throwable $throwable): void {}
}

class NotReadyHandler extends OrdinaryHandler implements SkippableTaskHandlerInterface
{
    public function shouldRun(): bool
    {
        return false;
    }

    public function skipDelaySeconds(): int
    {
        return 900;
    }
}

class ReadyHandler extends NotReadyHandler
{
    public function shouldRun(): bool
    {
        return true;
    }
}
