<?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 apexl\Io\modules\jobs\Interface\SkippableTaskHandlerInterface;
use DateTimeImmutable;
use ReflectionClass;
use ReflectionParameter;
use Throwable;
use function apexl\Io\container;

/**
 * @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;

    private ?QueueTaskHandlerInterface $emptyHandlerCache = null;
    private bool $emptyHandlerUnresolvable = false;

    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,
        ]);
    }

    /**
     * Whether the handler is willing to run this job now.
     *
     * Handlers that do not implement SkippableTaskHandlerInterface are always willing,
     * which is the behaviour everything had before the interface existed.
     */
    public function shouldRun(): bool
    {
        $handler = $this->inspectableHandler();

        return !$handler instanceof SkippableTaskHandlerInterface || $handler->shouldRun();
    }

    /**
     * The handler as an object to ask questions of, or null when it cannot be
     * built without its own contents.
     *
     * Resolution failure has to be survivable. handleFailure() runs inside
     * TaskRunnerService's catch, so a throw here escapes that catch and kills
     * the whole pass on its first bad job; and since shouldRun() now asks
     * before every job is locked, one unresolvable handler class would take
     * the queue down rather than just itself. Null means "no opinion", which
     * is the answer every job gave before these questions existed.
     */
    private function inspectableHandler(): ?QueueTaskHandlerInterface
    {
        if ($this->emptyHandlerUnresolvable) {
            return null;
        }

        try {
            return $this->emptyHandler();
        } catch (Throwable $throwable) {
            $this->emptyHandlerUnresolvable = true;
            error_log(
                sprintf(
                    '[QueueJob] handler %s could not be resolved for inspection: %s',
                    (string) $this->handler,
                    $throwable->getMessage(),
                )
            );

            return null;
        }
    }

    /**
     * Resolve a handler instance without setting `data`. Used by
     * handleFailure() to look up the handler class for the
     * RetryableTaskHandlerInterface check + final error callback.
     *
     * Visibility relaxed from `private` to `protected` 2026-05-23 (v3.0.12)
     * so tests can override with a stub handler — non-breaking.
     */
    protected function emptyHandler(): QueueTaskHandlerInterface
    {
        // Memoised: it depends only on $this->handler, and the skip path asks twice per job
        // — shouldRun() then skipDelaySeconds() — on top of handleFailure()'s own two calls.
        // Resolving a service through the container is not free when a blocked backlog is
        // being stood down thousands of times a minute.
        return $this->emptyHandlerCache ??= container()->make(
            $this->handler,
            $this->emptyHandlerParameters(),
        );
    }

    /**
     * What to pass for the handler's own contents when building it only to ask it
     * questions.
     *
     * No single literal works across the estate: `data` is `array $data` on some
     * handlers and a DTO on others, so whichever one is hardcoded here is a
     * TypeError for the rest — and since inspectableHandler() has to survive that,
     * it fails silently. null broke the array handlers; `[]` then broke every DTO
     * handler, which is worse than it sounds: shouldRun() answers "no opinion" for
     * a handler it cannot build, so a barriered queue quietly went back to locking,
     * attempting and erroring on every job behind the barrier — the exact cost the
     * skip path exists to avoid, with nothing above debug to say so.
     *
     * So the signature is asked rather than assumed. A parameter with a default is
     * left alone entirely, which is the honest answer: the handler has already said
     * what it wants when it holds nothing.
     */
    private function emptyHandlerParameters(): array
    {
        $parameter = $this->emptyHandlerDataParameter();

        if (!$parameter instanceof ReflectionParameter || $parameter->isDefaultValueAvailable()) {
            return [];
        }

        $type = $parameter->getType();

        return ['data' => $type === null || $type->allowsNull() ? null : []];
    }

    private function emptyHandlerDataParameter(): ?ReflectionParameter
    {
        $constructor = (new ReflectionClass($this->handler))->getConstructor();

        foreach ($constructor?->getParameters() ?? [] as $parameter) {
            if ($parameter->getName() === 'data') {
                return $parameter;
            }
        }

        return null;
    }

    /**
     * How long the handler wants to stand down for, when it declines to run.
     */
    public function skipDelaySeconds(): int
    {
        $handler = $this->inspectableHandler();

        return $handler instanceof SkippableTaskHandlerInterface ? $handler->skipDelaySeconds() : 0;
    }

    /**
     * Stand a job down without running or failing it: one write, and no attempt recorded,
     * because nothing was attempted. Unlike scheduleRetry() this is not a consequence of
     * failure, so `attempts` is left alone and the job's budget is untouched.
     */
    public function defer(int $delaySeconds): void
    {
        $this->run_after = time() + max(0, $delaySeconds);

        $this->store();
    }

    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->inspectableHandler();

        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();
    }

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

        $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();
    }
}
