<?php

declare(strict_types=1);

namespace apexl\Io\modules\jobs\Tests;

use apexl\Io\modules\jobs\Entity\QueueJob;
use apexl\Io\modules\jobs\Interface\QueueTaskHandlerInterface;
use apexl\Io\modules\jobs\Interface\RetryableTaskHandlerInterface;
use Arrayy\Arrayy;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use RuntimeException;
use Throwable;

/**
 * Phase D.P0 (2026-05-23) — first behaviour tests for QueueJob's failure
 * path. Verifies the v3.0.11 retry support (`run_after` + `scheduleRetry`)
 * AND the legacy non-retryable path that pre-dates it.
 *
 * QueueJob extends Entity, whose constructor pulls Vault + HookManager +
 * cache + a relation resolver out of the DI container. None of that
 * exists in a unit-test context, so:
 *  - We instantiate via `newInstanceWithoutConstructor()`.
 *  - We pre-populate the data backing-store via reflection (`$data`).
 *  - We use a thin subclass that overrides `store()` (no-op) and the
 *    Phase D.P0 newly-protected `emptyHandler()` (returns our stub).
 *
 * The store() override means database mutations from `incrementAttempts`,
 * `markFailed` and `scheduleRetry` never reach Vault — we observe the
 * mutations directly via the property/data array.
 */
final class QueueJobHandleFailureTest extends TestCase
{
    private function makeJob(
        QueueTaskHandlerInterface $handler,
        array $initialData = [],
    ): TestableQueueJob {
        $job = (new ReflectionClass(TestableQueueJob::class))
            ->newInstanceWithoutConstructor();
        $job->stubHandler = $handler;

        // Entity stores property access via a `data` array. Seed it with
        // the defaults handleFailure relies on (attempts, plus any caller-
        // supplied overrides). Other Entity infra (relations, virtual
        // fields, cache, hookManager) is only consulted on store() / find()
        // paths we don't exercise here, so the bypass is safe.
        $data = array_merge([
            'attempts'  => 0,
            'run_after' => 0,
            'locked'    => 0,
            'handler'   => 'StubHandler',
            'contents'  => null,
        ], $initialData);

        $ref = new ReflectionClass(QueueJob::class);
        while ($ref && !$ref->hasProperty('data')) {
            $ref = $ref->getParentClass() ?: null;
        }
        if ($ref !== null) {
            $prop = $ref->getProperty('data');
            $prop->setAccessible(true);
            $prop->setValue($job, $data);
        }

        return $job;
    }

    #[Test]
    public function retryable_handler_first_failure_schedules_retry_not_failure(): void
    {
        $handler = new SpyRetryableHandler(maxAttempts: 3, fixedDelay: 60);
        $before  = time();
        $job     = $this->makeJob($handler);

        $job->handleFailure('task-42', new RuntimeException('boom'));

        // attempts incremented from 0 to 1.
        self::assertSame(1, $job->attempts);
        // Job deferred — run_after set to roughly now + delay.
        self::assertGreaterThanOrEqual($before + 60, $job->run_after);
        self::assertLessThanOrEqual(time() + 60, $job->run_after);
        // Job NOT marked failed.
        self::assertNull($job->failed ?? null);
        // Handler.handleFailure called (final side-effect).
        self::assertCount(1, $handler->failures);
        self::assertSame('task-42', $handler->failures[0]['task']);
    }

    #[Test]
    public function retryable_handler_at_max_attempts_marks_failed(): void
    {
        $handler = new SpyRetryableHandler(maxAttempts: 3, fixedDelay: 60);
        // Already attempted 2 times — next failure makes it 3 = max.
        $job = $this->makeJob($handler, ['attempts' => 2]);

        $job->handleFailure('task-42', new RuntimeException('boom'));

        self::assertSame(3, $job->attempts);
        self::assertNotNull($job->failed);
        self::assertInstanceOf(\DateTimeInterface::class, $job->failed);
        self::assertCount(1, $handler->failures);
    }

    #[Test]
    public function retryable_handler_clamps_max_attempts_lower_bound_to_one(): void
    {
        // A handler returning 0 or negative maxAttempts shouldn't soft-fail
        // forever. handleFailure does `max(1, $handler->maxAttempts())`.
        $handler = new SpyRetryableHandler(maxAttempts: 0, fixedDelay: 60);
        $job     = $this->makeJob($handler);

        $job->handleFailure('task-x', new RuntimeException('nope'));

        // attempts=1, max(1,0)=1 → 1 >= 1 → markFailed.
        self::assertSame(1, $job->attempts);
        self::assertNotNull($job->failed);
    }

    #[Test]
    public function retryable_handler_clamps_negative_delay_to_zero(): void
    {
        // A handler that returns a negative delay shouldn't push run_after
        // into the past beyond now. scheduleRetry uses max(0, $delay).
        $handler = new SpyRetryableHandler(maxAttempts: 3, fixedDelay: -100);
        $before  = time();
        $job     = $this->makeJob($handler);

        $job->handleFailure('task-x', new RuntimeException('nope'));

        self::assertSame(1, $job->attempts);
        self::assertGreaterThanOrEqual($before, $job->run_after);
        self::assertLessThanOrEqual(time(), $job->run_after);
        self::assertNull($job->failed ?? null);
    }

    #[Test]
    public function legacy_non_retryable_first_failure_does_not_mark_failed(): void
    {
        $handler = new SpyHandler();
        $job     = $this->makeJob($handler);

        $job->handleFailure('task-42', new RuntimeException('boom'));

        // Legacy threshold is ATTEMPTS_BEFORE_FAILURE = 1; condition is
        // `attempts > 1`. After first failure attempts = 1, NOT > 1, so
        // job survives for one more attempt.
        self::assertSame(1, $job->attempts);
        self::assertNull($job->failed ?? null);
        // No run_after deferral on legacy handlers.
        self::assertSame(0, $job->run_after);
        self::assertCount(1, $handler->failures);
    }

    #[Test]
    public function legacy_non_retryable_second_failure_marks_failed(): void
    {
        $handler = new SpyHandler();
        $job     = $this->makeJob($handler, ['attempts' => 1]);

        $job->handleFailure('task-42', new RuntimeException('boom'));

        // attempts = 2, condition `2 > 1` → markFailed.
        self::assertSame(2, $job->attempts);
        self::assertNotNull($job->failed);
        self::assertCount(1, $handler->failures);
    }

    #[Test]
    public function handler_handle_failure_is_always_called_last(): void
    {
        // Both retryable + non-retryable paths must call the handler's
        // own handleFailure as a final side-effect (so domain code can
        // emit notifications, increment metrics, etc.).
        $retryable    = new SpyRetryableHandler(maxAttempts: 3, fixedDelay: 1);
        $nonRetryable = new SpyHandler();

        $this->makeJob($retryable)->handleFailure('a', new RuntimeException('x'));
        $this->makeJob($nonRetryable)->handleFailure('b', new RuntimeException('y'));

        self::assertCount(1, $retryable->failures);
        self::assertCount(1, $nonRetryable->failures);
    }
}

/**
 * Subclass that:
 *  (a) bypasses Entity's container-pulling constructor (the test
 *      constructs via newInstanceWithoutConstructor),
 *  (b) overrides store() to no-op so the failure-decision is observable
 *      via in-memory state without touching the DB, and
 *  (c) returns a test-supplied stub from emptyHandler() instead of
 *      hitting the framework's service container.
 */
final class TestableQueueJob extends QueueJob
{
    public ?QueueTaskHandlerInterface $stubHandler = null;

    // @phpstan-ignore-next-line constructor.missingParentCall
    public function __construct() { /* skip parent — see ReflectionClass usage in test */ }

    public function store(bool $timestamps = true): static
    {
        // No-op: tests observe state via the data array, not the DB.
        return $this;
    }

    protected function emptyHandler(): QueueTaskHandlerInterface
    {
        if ($this->stubHandler === null) {
            throw new \LogicException('TestableQueueJob::stubHandler not set');
        }
        return $this->stubHandler;
    }
}

final class SpyHandler implements QueueTaskHandlerInterface
{
    /** @var list<array{task:string,throwable:Throwable}> */
    public array $failures = [];

    public function run(): void {}

    public function handleFailure(string $task, Throwable $throwable): void
    {
        $this->failures[] = ['task' => $task, 'throwable' => $throwable];
    }
}

final class SpyRetryableHandler implements RetryableTaskHandlerInterface
{
    /** @var list<array{task:string,throwable:Throwable}> */
    public array $failures = [];

    public function __construct(
        private readonly int $maxAttempts,
        private readonly int $fixedDelay,
    ) {}

    public function run(): void {}

    public function handleFailure(string $task, Throwable $throwable): void
    {
        $this->failures[] = ['task' => $task, 'throwable' => $throwable];
    }

    public function maxAttempts(): int { return $this->maxAttempts; }

    public function retryDelaySeconds(int $attempt): int { return $this->fixedDelay; }
}
