<?php

declare(strict_types=1);

namespace Apexl\Io\Jobs\Tests;

use Apexl\Config\ConfigurationMock;
use Apexl\Io\Jobs\Commands\JobQueueCommand;
use Apexl\Io\Jobs\Entities\QueueJob;
use Apexl\Io\Jobs\Factories\QueueJobFactory;
use Apexl\Io\Jobs\Interfaces\QueueTaskHandlerInterface;
use Apexl\Io\Jobs\Interfaces\SequencedTaskHandlerInterface;
use Apexl\Io\Jobs\Services\QueueRecovery;
use Apexl\Io\TestInfrastructure\FeatureTest;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use Stringable;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
use Throwable;

/**
 * Renders the command, rather than testing the service underneath it.
 *
 * QueueRecoveryTest covers the state changes, and did so while `status` was fataling on
 * the first real failed job it met: `failed` comes back as a DateTimeImmutable and the
 * table cast it to a string. The service was never wrong — nothing had ever rendered its
 * output.
 *
 * This command is what someone reaches for when the queue is already stopped, so a fatal
 * here costs far more than it would anywhere else.
 */
final class JobQueueCommandTest extends FeatureTest
{
    /**
     * Configured here rather than taken from the host app: these tests run inside whichever
     * application installed the module, and an app that has narrowed `default_handlers` to
     * its own jobs would put every fixture below out of scope.
     *
     * @param class-string[]|null $defaultHandlers
     */
    private function tester(?array $defaultHandlers = null): CommandTester
    {
        return new CommandTester(new JobQueueCommand(
            \Apexl\Io\container()->get(QueueRecovery::class),
            new ConfigurationMock(['jobs.default_handlers' => $defaultHandlers]),
        ));
    }

    /**
     * @param class-string $handler
     */
    private function failedJob(string $handler = SequencedFakeHandler::class): QueueJob
    {
        $job = \Apexl\Io\container()->get(QueueJobFactory::class)->make(
            $handler,
            new DescribedPayload('tyre_history', 115195),
        );

        $job->failed = new DateTimeImmutable();
        $job->attempts = 1;
        $job->store();

        // Reloaded, because the shapes that matter are the ones the database hands back —
        // an in-memory entity still holds whatever was assigned to it.
        return QueueJob::fromPrimaryKey($job->id);
    }

    private function deferredJob(string $handler = SequencedFakeHandler::class): QueueJob
    {
        $job = \Apexl\Io\container()->get(QueueJobFactory::class)->make(
            $handler,
            new DescribedPayload('tyre_history', 115196),
            runAfter: time() + 900,
        );

        $job->attempts = 12;
        $job->store();

        return $job;
    }

    #[Test]
    public function statusRendersARealFailedJob(): void
    {
        $job = $this->failedJob();

        $tester = $this->tester();
        $tester->execute(['action' => 'status']);

        $tester->assertCommandIsSuccessful();

        $output = $tester->getDisplay();

        self::assertStringContainsString('1 failed job(s)', $output);
        self::assertStringContainsString((string) $job->id, $output);
        self::assertStringContainsString('tyre_history', $output);
        self::assertStringContainsString('115195', $output);
    }

    #[Test]
    public function statusIsHappyWithAnEmptyQueue(): void
    {
        $tester = $this->tester();
        $tester->execute(['action' => 'status']);

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('No failed job(s)', $tester->getDisplay());
    }

    /**
     * "Blocking" is a claim about the other jobs, and only a sequenced handler can make it.
     * Saying it of an ordinary handler sends somebody looking for a barrier that is not
     * there.
     */
    #[Test]
    public function onlyASequencedHandlerIsDescribedAsBlocking(): void
    {
        $this->failedJob(PlainFakeHandler::class);

        $tester = $this->tester();
        $tester->execute(['action' => 'status']);

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('alongside', $tester->getDisplay());
        self::assertStringNotContainsString('blocking', $tester->getDisplay());
    }

    #[Test]
    public function showRendersTheStoredPayload(): void
    {
        $job = $this->failedJob();

        $tester = $this->tester();
        $tester->execute(['action' => 'show', 'id' => (string) $job->id]);

        $tester->assertCommandIsSuccessful();

        $output = $tester->getDisplay();

        self::assertStringContainsString('115195', $output);
        self::assertStringNotContainsString('failed:   -', $output, 'The failure time must be shown.');
    }

    /**
     * The backlog behind a sequenced handler is deferred by somebody else's failure, and
     * its attempt count was inflated by the same blocking. Clearing the barrier without
     * bringing it forward leaves it trickling back at the handler's backoff ceiling.
     */
    #[Test]
    public function retryClearsTheBarrierAndBringsTheBacklogForward(): void
    {
        $job = $this->failedJob();
        $deferred = $this->deferredJob();

        $tester = $this->tester();
        $tester->execute(['action' => 'retry', 'id' => (string) $job->id]);

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('Queue is clear', $tester->getDisplay());
        self::assertNull(QueueJob::fromPrimaryKey($job->id)->failed);

        $released = QueueJob::fromPrimaryKey($deferred->id);

        self::assertSame(0, $released->run_after);
        self::assertSame(0, $released->attempts);
    }

    /**
     * An ordinary handler's deferral is its own backoff, earned by its own failures, so the
     * backlog is left exactly where it is — resetting those attempts would loop the job
     * forever.
     */
    #[Test]
    public function retryLeavesAnOrdinaryHandlersBackoffAlone(): void
    {
        $job = $this->failedJob(PlainFakeHandler::class);
        $deferred = $this->deferredJob(PlainFakeHandler::class);

        $tester = $this->tester();
        $tester->execute(['action' => 'retry', 'id' => (string) $job->id]);

        $tester->assertCommandIsSuccessful();
        self::assertStringNotContainsString('brought forward', $tester->getDisplay());

        $untouched = QueueJob::fromPrimaryKey($deferred->id);

        self::assertGreaterThan(time(), $untouched->run_after);
        self::assertSame(12, $untouched->attempts);
    }

    #[Test]
    public function discardLosesTheChangeButKeepsThePayload(): void
    {
        $job = $this->failedJob();

        $tester = $this->tester();
        $tester->execute(['action' => 'discard', 'id' => (string) $job->id]);

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('will not be applied', $tester->getDisplay());

        $discarded = QueueJob::fromPrimaryKey($job->id);

        self::assertNull($discarded->failed);
        self::assertSame(115195, $discarded->contents->ref);
    }

    /**
     * A job processed the ordinary way has had its `contents` blanked by
     * QueueJob::markProcessed(), so the payload is not always an object.
     */
    #[Test]
    public function showSurvivesAJobWithNoPayloadLeft(): void
    {
        $job = $this->failedJob();
        $job->markProcessed();

        $tester = $this->tester();
        $tester->execute(['action' => 'show', 'id' => (string) $job->id]);

        $tester->assertCommandIsSuccessful();
    }

    #[Test]
    public function theHandlerOptionNarrowsTheScope(): void
    {
        $this->failedJob(PlainFakeHandler::class);
        $this->deferredJob(SequencedFakeHandler::class);

        $tester = $this->tester();
        $tester->execute(['action' => 'status', '--handler' => 'SequencedFakeHandler']);

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('No failed SequencedFakeHandler job(s)', $tester->getDisplay());
        self::assertStringContainsString('1 SequencedFakeHandler job(s) waiting', $tester->getDisplay());
    }

    /**
     * A handler with nothing queued is not a name anyone can act on, and the queue's own
     * list is the only thing that tells you what is there.
     */
    #[Test]
    public function aHandlerWithNothingQueuedIsNotAScope(): void
    {
        $this->failedJob(PlainFakeHandler::class);

        $tester = $this->tester();
        $exit = $tester->execute(['action' => 'status', '--handler' => 'SequencedFakeHandler']);

        self::assertSame(Command::INVALID, $exit);
    }

    #[Test]
    public function anUnknownHandlerSaysWhatIsActuallyThere(): void
    {
        $this->failedJob(PlainFakeHandler::class);

        $tester = $this->tester();
        $exit = $tester->execute(['action' => 'status', '--handler' => 'NoSuchJob']);

        self::assertSame(Command::INVALID, $exit);
        self::assertStringContainsString('PlainFakeHandler', $tester->getDisplay());
    }

    #[Test]
    public function handlersListsEverythingInTheQueueAndWhichOnesSequence(): void
    {
        $this->failedJob();
        $this->failedJob(PlainFakeHandler::class);

        $tester = $this->tester();
        $tester->execute(['action' => 'handlers']);

        $tester->assertCommandIsSuccessful();

        $output = $tester->getDisplay();

        self::assertStringContainsString('SequencedFakeHandler', $output);
        self::assertStringContainsString('PlainFakeHandler', $output);
        self::assertMatchesRegularExpression(
            '/SequencedFakeHandler\s*\|\s*yes/',
            $output,
            'Which handlers sequence decides what retry may bring forward.',
        );
    }

    /**
     * The configured default is what the bare command means. It has to say so — somebody
     * reading "nothing has failed" cannot otherwise tell they are being shown two handlers
     * out of nine.
     */
    #[Test]
    public function aConfiguredDefaultNarrowsTheBareCommandAndSaysSo(): void
    {
        $this->failedJob(PlainFakeHandler::class);

        $tester = $this->tester([SequencedFakeHandler::class]);
        $tester->execute(['action' => 'status']);

        $tester->assertCommandIsSuccessful();

        $output = $tester->getDisplay();

        self::assertStringContainsString('SequencedFakeHandler only', $output);
        self::assertStringContainsString('No failed job(s)', $output);
        self::assertStringNotContainsString('PlainFakeHandler', $output);
    }

    #[Test]
    public function handlerAllReachesPastTheConfiguredDefault(): void
    {
        $this->failedJob(PlainFakeHandler::class);

        $tester = $this->tester([SequencedFakeHandler::class]);
        $tester->execute(['action' => 'status', '--handler' => 'all']);

        $tester->assertCommandIsSuccessful();

        $output = $tester->getDisplay();

        self::assertStringContainsString('1 failed job(s)', $output);
        self::assertStringNotContainsString('only', $output, 'Nothing is being hidden to explain.');
    }

    /** The whole queue is always listable, whatever the default hides. */
    #[Test]
    public function handlersIgnoresTheConfiguredDefault(): void
    {
        $this->failedJob(PlainFakeHandler::class);

        $tester = $this->tester([SequencedFakeHandler::class]);
        $tester->execute(['action' => 'handlers']);

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('PlainFakeHandler', $tester->getDisplay());
    }

    #[Test]
    public function sweepReportsNothingToDo(): void
    {
        $tester = $this->tester();
        $tester->execute(['action' => 'sweep']);

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('No locked jobs', $tester->getDisplay());
    }
}

/** A payload that knows how to name itself, the way an ETL sync payload does. */
final readonly class DescribedPayload implements Stringable
{
    public function __construct(public string $table, public int $ref) {}

    public function __toString(): string
    {
        return sprintf('%s %s', $this->table, $this->ref);
    }
}

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

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

class SequencedFakeHandler extends PlainFakeHandler implements SequencedTaskHandlerInterface {}
