<?php

declare(strict_types=1);

namespace apexl\Io\modules\jobs\Entity;

use apexl\entityCore\enums\Casts;
use apexl\entityCore\traits\hasCasts;
use apexl\Io\includes\Entity;
use apexl\Io\includes\System;
use apexl\Io\modules\jobs\Interface\QueueTaskHandlerInterface;
use apexl\Io\modules\jobs\Interface\RetryableTaskHandlerInterface;
use DateTimeImmutable;
use Throwable;

/**
 * @property int $id
 * @property int $sequence
 * @property class-string $handler
 * @property DateTimeImmutable $created
 * @property bool $locked
 * @property int $attempts
 * @property int $run_after epoch; the task finder skips this job until now >= run_after (0 = due now)
 * @property DateTimeImmutable $processed
 * @property DateTimeImmutable $failed
 * @property mixed $contents
 */
class QueueJob extends Entity
{
    use hasCasts {
        hasCasts::casts as traitCasts;
    }

    public final const string TABLE = 'job_queue';
    private const int ATTEMPTS_BEFORE_FAILURE = 1;

    public function __construct()
    {
        parent::__construct(self::TABLE);
    }

    public function casts(): array
    {
        return [
            ...$this->traitCasts(),
            'created' => Casts::DATETIME,
            'contents' => Casts::SERIALIZE,
        ];
    }

    public function run(): void
    {
        $this->handler()->run();
    }

    private function handler(): QueueTaskHandlerInterface
    {
        return System::makeRegisteredService($this->handler, [
            'data' => $this->contents,
        ]);
    }

    public function lock(): void
    {
        $this->locked = true;

        $this->store();
    }

    public function unlock(): void
    {
        $this->locked = false;

        $this->store();
    }

    public function markProcessed(): void
    {
        $this->processed = new DateTimeImmutable();

        /** @Todo Add config to turn clearing contents on / off */
        $this->data['contents'] = '';

        $this->store();
    }

    public function handleFailure(string $task, Throwable $throwable): void
    {
        $this->incrementAttempts();

        $handler = $this->emptyHandler();

        if ($handler instanceof RetryableTaskHandlerInterface) {
            // Opt-in backoff: defer the job until it's exhausted its budget.
            if ($this->attempts >= max(1, $handler->maxAttempts())) {
                $this->markFailed();
            } else {
                $this->scheduleRetry($handler->retryDelaySeconds($this->attempts));
            }
        } elseif ($this->attempts > self::ATTEMPTS_BEFORE_FAILURE) {
            // Legacy behaviour, unchanged for non-retryable handlers.
            $this->markFailed();
        }

        $handler->handleFailure($task, $throwable);
    }

    private function incrementAttempts(): void
    {
        $this->attempts++;

        $this->store();
    }

    /**
     * Defer this job until `now + delay`. Leaves it unprocessed + unfailed
     * so the task finder picks it up again once due.
     */
    private function scheduleRetry(int $delaySeconds): void
    {
        $this->run_after = time() + max(0, $delaySeconds);

        $this->store();
    }

    private function markFailed(): void
    {
        $this->failed = new DateTimeImmutable();

        $this->store();
    }

    private function emptyHandler(): QueueTaskHandlerInterface
    {
        /** @var ?QueueTaskHandlerInterface $handler */
        return System::makeRegisteredService($this->handler, [
            'data' => null,
        ]);
    }
}
