<?php

declare(strict_types=1);

namespace Apexl\Io\Jobs\Tests;

use Apexl\Io\Jobs\Entities\QueueJob;
use Apexl\Io\Jobs\Factories\QueueJobFactory;
use Apexl\Io\Jobs\Interfaces\QueueTaskHandlerInterface;
use Apexl\Io\Jobs\Services\QueueRecovery;
use Apexl\Io\TestInfrastructure\FeatureTest;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use stdClass;
use Throwable;

/**
 * Getting the queue moving again.
 *
 * A failed job is a dead end by design — the task finder skips it forever — and where the
 * handler is a sequenced one it holds everything behind it as well, so nothing applies out
 * of order. Nothing in the queue clears that state, so without this service the only way
 * back is hand-written UPDATEs against job_queue at whatever hour it happened.
 */
final class QueueRecoveryTest extends FeatureTest
{
    private function recovery(): QueueRecovery
    {
        return \Apexl\Io\container()->get(QueueRecovery::class);
    }

    private function job(
        ?DateTimeImmutable $failed = null,
        int $attempts = 0,
        int $runAfter = 0,
        bool $locked = false,
    ): QueueJob {
        $payload = new stdClass();
        $payload->ref = 115195;

        $job = \Apexl\Io\container()->get(QueueJobFactory::class)->make(
            RecoverableHandler::class,
            $payload,
            runAfter: $runAfter,
        );

        $job->failed = $failed;
        $job->attempts = $attempts;
        $job->locked = $locked;
        $job->store();

        return $job;
    }

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

    /**
     * The per-handler breakdown is aggregated in SQL, so nothing else would notice if a
     * column stopped counting what it says it does.
     */
    #[Test]
    public function handlersCountsEachStateSeparately(): void
    {
        $this->job(failed: new DateTimeImmutable());
        $this->job();
        $this->job(runAfter: time() + 900);
        $this->job(locked: true);

        $summaries = $this->recovery()->handlers();

        self::assertCount(1, $summaries, 'Only one handler has anything queued.');

        $summary = $summaries[0];

        self::assertSame(RecoverableHandler::class, $summary->handler);
        self::assertSame(1, $summary->failed);
        self::assertSame(3, $summary->queued, 'Failed jobs are not queued.');
        self::assertSame(1, $summary->deferred);
        self::assertSame(1, $summary->locked);
    }

    #[Test]
    public function failedListsWhatIsBlockingTheQueue(): void
    {
        $blocking = $this->job(failed: new DateTimeImmutable());
        $this->job();

        $failed = $this->recovery()->failed();

        self::assertCount(1, $failed);
        self::assertSame($blocking->id, $failed[0]->id);
    }

    #[Test]
    public function blockedCountIsWhatIsWaitingBehindIt(): void
    {
        $this->job(failed: new DateTimeImmutable());
        $this->job();
        $this->job();

        self::assertSame(2, $this->recovery()->blockedCount());
    }

    /**
     * The handler filter is what lets one wedged handler be dealt with without touching
     * anything else in the queue.
     */
    #[Test]
    public function everyQuestionCanBeNarrowedToAHandler(): void
    {
        $this->job(failed: new DateTimeImmutable());

        self::assertCount(1, $this->recovery()->failed([RecoverableHandler::class]));
        self::assertSame([], $this->recovery()->failed([OtherRecoverableHandler::class]));
        self::assertSame(0, $this->recovery()->blockedCount([OtherRecoverableHandler::class]));
    }

    /**
     * `attempts` matters as much as `failed` here. A handler capping attempts at 1 — the
     * usual choice for a job that fails on its own merits — would otherwise mark the job
     * failed again the instant it threw, before the retry told anyone anything new.
     */
    #[Test]
    public function retryPutsTheJobBackInTheQueue(): void
    {
        $job = $this->job(failed: new DateTimeImmutable(), attempts: 1, runAfter: time() + 900, locked: true);

        $this->recovery()->retry($job);

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

        self::assertNull($requeued->failed);
        self::assertSame(0, $requeued->attempts);
        self::assertSame(0, $requeued->run_after);
        self::assertFalse($requeued->locked);
        self::assertSame([], $this->recovery()->failed(), 'The barrier must be gone.');
    }

    /**
     * The payload is deliberately kept, unlike QueueJob::markProcessed() which blanks it.
     * A discarded job is work that was asked for and never done, and the payload is the
     * only record of what it was.
     */
    #[Test]
    public function discardLiftsTheBarrierWithoutRunningTheJob(): void
    {
        $job = $this->job(failed: new DateTimeImmutable());

        $this->recovery()->discard($job);

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

        self::assertNull($discarded->failed);
        self::assertNotNull($discarded->processed);
        self::assertSame(115195, $discarded->contents->ref, 'The payload is the only record of what was lost.');
        self::assertSame([], $this->recovery()->failed());
    }

    /**
     * Clearing the barrier is not enough on its own. Every job held behind it has been
     * backing off towards its handler's ceiling, so a backlog that blocked overnight would
     * trickle back a few jobs an hour unless it is brought forward.
     */
    #[Test]
    public function releaseDeferredBringsTheBacklogForward(): void
    {
        $deferred = $this->job(attempts: 15, runAfter: time() + 900);
        $due = $this->job();

        self::assertSame(1, $this->recovery()->releaseDeferred());

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

        self::assertSame(0, $released->run_after);
        self::assertSame(0, $released->attempts, 'Attempts inflated by blocking mean nothing.');
        self::assertSame(0, $this->reload($due)->run_after);
    }

    #[Test]
    public function releaseDeferredLeavesAFailedJobAlone(): void
    {
        $failed = $this->job(failed: new DateTimeImmutable(), attempts: 1, runAfter: time() + 900);

        self::assertSame(0, $this->recovery()->releaseDeferred());
        self::assertNotNull($this->reload($failed)->failed);
    }

    /**
     * The silent stall. TaskRunnerService unlocks in its catch block, so anything PHP
     * cannot catch leaves the row locked — and the task finder skips locked rows, so the
     * job is not failed, not retried and not reported. It simply vanishes while everything
     * behind it runs on, out of sequence.
     */
    #[Test]
    public function staleFindsWhatACrashedWorkerLeftBehind(): void
    {
        $stuck = $this->job(locked: true);
        $this->job();

        $stale = $this->recovery()->stale();

        self::assertCount(1, $stale);
        self::assertSame($stuck->id, $stale[0]->id);
    }

    /**
     * Failed rather than quietly unlocked: we do not know how far it got, and a job is not
     * idempotent in general. Failing it makes it the barrier, which puts the decision in
     * front of a person with the payload in hand.
     */
    #[Test]
    public function failingAStaleJobMakesItTheBarrier(): void
    {
        $stuck = $this->job(locked: true);

        $this->recovery()->failStale($stuck);

        $failed = $this->reload($stuck);

        self::assertNotNull($failed->failed);
        self::assertFalse($failed->locked);
        self::assertCount(1, $this->recovery()->failed());
    }
}

/** Never run: these tests only ever put rows in the queue and take them out again. */
class RecoverableHandler implements QueueTaskHandlerInterface
{
    public function run(): void {}

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

/** A second handler, so "narrowed to a handler" can mean something. */
class OtherRecoverableHandler extends RecoverableHandler {}
