<?php

declare(strict_types=1);

namespace apexl\Io\modules\jobs\Tests;

use apexl\Io\collections\LoaderSortCollection;
use apexl\Io\dto\LoaderCondition;
use apexl\Io\dto\LoaderSort;
use apexl\Io\modules\jobs\Entity\QueueJob;
use apexl\Io\modules\jobs\Service\QueuedTaskFinderDb;
use apexl\Io\TestInfrastructure\FeatureTest;
use apexl\Vault\enums\SortDirection;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;

/**
 * Exercises the queued-task finder against a real database — ordering,
 * eligibility filtering and the raw keyset cursor it builds to page through
 * jobs without OFFSET.
 *
 * Relies on the app's {@see FeatureTest} harness (io-core) for schema + DB, so
 * it runs as part of the host application's suite rather than io-jobs alone.
 */
class QueuedTaskFinderDbTest extends FeatureTest
{
    /** @return int[] flat list of job ids in the order the finder yields them */
    private function taskIds(int $limit): array
    {
        $ids = [];
        foreach ((new QueuedTaskFinderDb())->tasks($limit) as $batch) {
            foreach ($batch as $task) {
                // QueueJobTask::name() => "Queue Job {id} ({handler})"
                preg_match('/Queue Job (\d+)/', $task->name(), $m);
                $ids[] = (int) $m[1];
            }
        }

        return $ids;
    }

    private function makeJob(int $sequence, string $handler, array $overrides = []): QueueJob
    {
        $job = new QueueJob();
        $job->sequence = $sequence;
        $job->handler = $handler;
        $job->created = new DateTimeImmutable();
        $job->contents = [];
        $job->locked = false;
        $job->attempts = 0;
        $job->run_after = 0;

        foreach ($overrides as $field => $value) {
            $job->{$field} = $value;
        }

        $job->store();

        return $job;
    }

    #[Test]
    public function ordersBySequenceThenIdAndExcludesIneligibleJobs(): void
    {
        // Insertion order fixes the auto-increment ids (1..8).
        $id1 = $this->makeJob(100, 'A')->id;                                   // due
        $id2 = $this->makeJob(100, 'B')->id;                                   // due, same seq
        $id3 = $this->makeJob(50, 'A')->id;                                    // due, lower seq / higher id
        $this->makeJob(200, 'A', ['locked' => true]);                         // excluded: locked
        $this->makeJob(60, 'A', ['processed' => new DateTimeImmutable()]);    // excluded: processed
        $this->makeJob(70, 'A', ['failed' => new DateTimeImmutable()]);       // excluded: failed
        $this->makeJob(80, 'A', ['run_after' => time() + 3600]);             // excluded: deferred
        $id8 = $this->makeJob(100, 'C')->id;                                   // due, same seq

        // sequence leads (id3's low sequence sorts it first despite its higher
        // id); id only breaks ties between equal sequences.
        self::assertSame([$id3, $id1, $id2, $id8], $this->taskIds(10));
    }

    #[Test]
    public function rawKeysetCursorSeeksPastTheTuple(): void
    {
        // Auto-increment ids are not reset between transaction-isolated tests,
        // so capture real ids rather than assuming they start at 1.
        $id1 = $this->makeJob(100, 'A')->id;
        $id2 = $this->makeJob(100, 'B')->id;
        $id3 = $this->makeJob(50, 'A')->id;
        $id4 = $this->makeJob(100, 'C')->id;

        // Mirror the cursor the finder builds after yielding row (sequence=100, id=$id1).
        $rows = QueueJob::findMultiple(
            conditions: [
                LoaderCondition::isNull('processed'),
                LoaderCondition::isNull('failed'),
                LoaderCondition::equals('locked', 0),
                LoaderCondition::lessThanOrEqual('run_after', time()),
                LoaderCondition::raw(
                    '(sequence > :cursor_sequence_gt'
                    . ' OR (sequence = :cursor_sequence_eq AND id > :cursor_id_gt))',
                    [
                        'cursor_sequence_gt' => 100,
                        'cursor_sequence_eq' => 100,
                        'cursor_id_gt' => $id1,
                    ],
                ),
            ],
            limit: 10,
            sorts: LoaderSortCollection::createFromArray([
                new LoaderSort('sequence', SortDirection::ASC),
                new LoaderSort('id', SortDirection::ASC),
            ]),
        );

        $ids = $rows->map(fn(QueueJob $j) => $j->id)->getArray();

        // id3 (seq 50) is behind the cursor and excluded; only the same-sequence
        // rows with a higher id survive, in (sequence, id) order.
        self::assertSame([$id2, $id4], $ids);
        self::assertNotContains($id3, $ids);
    }
}
