<?php

declare(strict_types=1);

namespace Apexl\Io\Jobs\Services;

use Apexl\Io\Collections\LoaderSortCollection;
use Apexl\Io\Dtos\LoaderCondition;
use Apexl\Io\Dtos\LoaderSort;
use Apexl\Io\Exceptions\RecordNotFoundException;
use Apexl\Io\Jobs\Dtos\QueueHandlerSummary;
use Apexl\Io\Jobs\Entities\QueueJob;
use Apexl\Io\Jobs\Interfaces\SequencedTaskHandlerInterface;
use Apexl\Vault\Enums\SortDirection;
use Apexl\Vault\Enums\WhereCondition;
use Apexl\Vault\Vault;
use DateTimeImmutable;

/**
 * Getting the queue moving again after a job has failed.
 *
 * A failed job is a dead end on its own: QueuedTaskFinderDb filters on `failed IS NULL`, so
 * nothing will ever pick it up again. For a handler whose jobs must apply in order
 * ({@see SequencedTaskHandlerInterface}) it is worse
 * than a dead end — it holds every job behind it as well. Either way clearing the state is
 * the only way out, and the state is only reachable through the database.
 *
 * Every method takes the same `$handlers` filter: null is the whole queue, a list narrows
 * it to those handlers.
 */
final readonly class QueueRecovery
{
    public function __construct(private Vault $db) {}

    /**
     * Jobs sitting in a failed state, oldest sequence first — the barrier, when the
     * handlers in scope are sequenced ones.
     *
     * @param class-string[]|null $handlers
     *
     * @return QueueJob[]
     */
    public function failed(?array $handlers = null, int $limit = 50): array
    {
        return QueueJob::findMultiple(
            conditions: $this->scoped($handlers, [
                LoaderCondition::notNull('failed'),
            ]),
            limit: $limit,
            sorts: LoaderSortCollection::createFromArray([
                new LoaderSort('sequence', SortDirection::ASC),
                new LoaderSort('id', SortDirection::ASC),
            ]),
        )->getArray();
    }

    /**
     * How many jobs are queued — behind the barrier, when the scope is the sequenced set.
     *
     * @param class-string[]|null $handlers
     */
    public function blockedCount(?array $handlers = null): int
    {
        return $this->countWhere($this->scoped($handlers, [
            LoaderCondition::isNull('failed'),
            LoaderCondition::isNull('processed'),
        ]));
    }

    /**
     * What is actually in the queue, per handler. Answers "what can I pass to --handler?"
     * as well as feeding the status breakdown.
     *
     * Aggregated in SQL rather than by loading rows: a blocked backlog is routinely
     * thousands of jobs, and a processed one stays in the table.
     *
     * @return QueueHandlerSummary[]
     */
    public function handlers(): array
    {
        $rows = $this->db->executeSql(
        /** @lang TEXT */
            sprintf(
                <<<'SQL'
                SELECT handler,
                       SUM(CASE WHEN processed IS NULL AND failed IS NULL THEN 1 ELSE 0 END) AS queued,
                       SUM(CASE WHEN failed IS NOT NULL THEN 1 ELSE 0 END) AS failed,
                       SUM(CASE WHEN processed IS NULL AND failed IS NULL AND run_after > 0 THEN 1 ELSE 0 END) AS deferred,
                       SUM(CASE WHEN processed IS NULL AND failed IS NULL AND locked = 1 THEN 1 ELSE 0 END) AS locked
                FROM %s
                GROUP BY handler
                ORDER BY handler
                SQL,
                QueueJob::TABLE,
            )
        )->fetchAllAssoc() ?: [];

        return array_map(
            static fn (array $row): QueueHandlerSummary => new QueueHandlerSummary(
                (string) $row['handler'],
                (int) $row['queued'],
                (int) $row['failed'],
                (int) $row['deferred'],
                (int) $row['locked'],
            ),
            $rows,
        );
    }

    /**
     * @param class-string[]|null $handlers
     * @param LoaderCondition[] $conditions
     *
     * @return LoaderCondition[]
     */
    private function scoped(?array $handlers, array $conditions): array
    {
        return $handlers === null
            ? $conditions
            : [LoaderCondition::in('handler', $handlers), ...$conditions];
    }

    /**
     * A count without dragging the rows back: findMultiple's by-ref $paging runs its own
     * count(*) query, and a blocked backlog is routinely in the thousands.
     *
     * @param LoaderCondition[] $conditions
     */
    private function countWhere(array $conditions): int
    {
        $paging = null;

        QueueJob::findMultiple(
            conditions: $conditions,
            limit: 1,
            fields: ['id'],
            paging: $paging,
        );

        return (int) ($paging[0] ?? 0);
    }

    /**
     * @throws RecordNotFoundException
     */
    public function job(int $id): QueueJob
    {
        return QueueJob::fromPrimaryKey($id);
    }

    /**
     * Put a failed job back in the queue.
     *
     * `attempts` is reset as well as `failed`, because maxAttempts() is 1 for a job
     * failing on its own merits — leaving the count at 1 would mark it failed again the
     * instant it threw, before the retry told us anything new. `run_after` and `locked`
     * are cleared so it is due immediately rather than serving out a backoff it earned
     * while blocked.
     */
    public function retry(QueueJob $job): void
    {
        $job->failed = null;
        $job->attempts = 0;
        $job->run_after = 0;
        $job->locked = false;

        $job->store();
    }

    /**
     * Give up on a job: mark it processed so the barrier lifts, without running it.
     *
     * `contents` is deliberately left alone, unlike QueueJob::markProcessed(), which
     * blanks it. A discarded job is work that was asked for and never done — the payload is
     * the only record of what was lost.
     */
    public function discard(QueueJob $job): void
    {
        $job->failed = null;
        $job->locked = false;
        $job->processed = new DateTimeImmutable();

        $job->store();
    }

    /**
     * Bring every deferred job in scope forward to now.
     *
     * Without this, clearing the barrier does not restart the queue — each blocked job
     * has been backing off in 60s steps up to a 15-minute ceiling, so a backlog that
     * blocked overnight would then trickle back at four jobs an hour. Their `attempts`
     * are inflated by the same blocking and mean nothing, so they go too.
     *
     * Resetting `attempts` is only right for jobs deferred by a barrier they had no part
     * in, so this is called for the sequenced handlers. A job backing off from its own
     * failures has earned its attempt count, and clearing it would loop it forever.
     *
     * @param class-string[]|null $handlers
     *
     * @return int Jobs brought forward.
     */
    public function releaseDeferred(?array $handlers = null): int
    {
        $deferred = $this->countWhere($this->scoped($handlers, [
            LoaderCondition::isNull('failed'),
            LoaderCondition::isNull('processed'),
            LoaderCondition::greaterThan('run_after', 0),
        ]));

        if ($deferred === 0) {
            return 0;
        }

        // One statement rather than a store() per row: a backlog is routinely thousands
        // of jobs, and each store() is a select and an update.
        $update = $this->db->update(QueueJob::TABLE)
            ->fields([
                'run_after' => 0,
                'attempts' => 0,
            ])
            ->where('failed', null, WhereCondition::IS_NULL)
            ->where('processed', null, WhereCondition::IS_NULL)
            ->where('run_after', 0, WhereCondition::GREATER_THAN);

        if ($handlers !== null) {
            $update->where('handler', $handlers, WhereCondition::IN);
        }

        $update->execute();

        return $deferred;
    }

    /**
     * Jobs left locked by a worker that died mid-run.
     *
     * `locked` is cleared in TaskRunnerService's catch block, so anything PHP cannot
     * catch — the OOM killer, a segfault, the box going down — leaves it set. The task
     * finder skips locked rows, so such a job is not failed, not retried and not
     * reported: it silently vanishes from the queue while everything behind it carries
     * on running, out of order.
     *
     * Only safe to call when no worker is running, since a locked row is indistinguishable
     * from one legitimately in flight — a wrapper that holds a lock around both the worker
     * and the sweep is the way to be sure.
     *
     * @param class-string[]|null $handlers
     *
     * @return QueueJob[]
     */
    public function stale(?array $handlers = null): array
    {
        return QueueJob::findMultiple(
            conditions: $this->scoped($handlers, [
                LoaderCondition::equals('locked', 1),
                LoaderCondition::isNull('failed'),
                LoaderCondition::isNull('processed'),
            ]),
            limit: null,
            sorts: LoaderSortCollection::createFromArray([
                new LoaderSort('sequence', SortDirection::ASC),
                new LoaderSort('id', SortDirection::ASC),
            ]),
        )->getArray();
    }

    /**
     * Fail a job whose worker died, rather than quietly unlocking it.
     *
     * We do not know how far it got. A job is not idempotent in general — a DELETE that
     * already applied, an insert that half-wrote — so re-running it blind is not obviously
     * safer than not running it. Failing it makes it the barrier, which puts
     * the decision in front of a person with the payload in hand.
     */
    public function failStale(QueueJob $job): void
    {
        $job->locked = false;
        $job->failed = new DateTimeImmutable();

        $job->store();
    }
}
