<?php

namespace Tests\Unit\Domain\CronJob;

use Domain\CronJob\CronJobRepository;
use Domain\CronJob\CronJobType;
use PHPUnit\Framework\TestCase;

class CronJobRepositoryTest extends TestCase
{
    /** @var \PHPUnit\Framework\MockObject\MockObject|\medoo */
    private $mockDb;

    /** @var CronJobRepository */
    private $repo;

    protected function setUp(): void
    {
        $this->mockDb = $this->createMock(\medoo::class);
        $this->repo = new CronJobRepository($this->mockDb);
    }

    // --- enqueue ---

    public function testEnqueueInsertsJobAndReturnsId(): void
    {
        $this->mockDb->expects($this->once())
            ->method('insert')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) {
                    return $data['job_type'] === 'apilo_send_order'
                        && $data['status'] === 'pending'
                        && $data['priority'] === 50
                        && $data['max_attempts'] === 10
                        && isset($data['scheduled_at']);
                })
            );

        $this->mockDb->method('id')->willReturn('42');

        $id = $this->repo->enqueue('apilo_send_order', null, CronJobType::PRIORITY_HIGH);

        $this->assertSame(42, $id);
    }

    public function testEnqueueWithPayloadEncodesJson(): void
    {
        $payload = ['order_id' => 123, 'action' => 'sync'];

        $this->mockDb->expects($this->once())
            ->method('insert')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) use ($payload) {
                    return $data['payload'] === json_encode($payload);
                })
            );

        $this->mockDb->method('id')->willReturn('1');

        $this->repo->enqueue('apilo_sync_payment', $payload);
    }

    public function testEnqueueWithoutPayloadDoesNotSetPayloadKey(): void
    {
        $this->mockDb->expects($this->once())
            ->method('insert')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) {
                    return !array_key_exists('payload', $data);
                })
            );

        $this->mockDb->method('id')->willReturn('1');

        $this->repo->enqueue('price_history');
    }

    public function testEnqueueWithScheduledAt(): void
    {
        $scheduled = '2026-03-01 10:00:00';

        $this->mockDb->expects($this->once())
            ->method('insert')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) use ($scheduled) {
                    return $data['scheduled_at'] === $scheduled;
                })
            );

        $this->mockDb->method('id')->willReturn('1');

        $this->repo->enqueue('price_history', null, CronJobType::PRIORITY_NORMAL, 10, $scheduled);
    }

    public function testEnqueueReturnsNullOnFailure(): void
    {
        $this->mockDb->method('insert');
        $this->mockDb->method('id')->willReturn(null);

        $id = $this->repo->enqueue('test_job');
        $this->assertNull($id);
    }

    // --- fetchNext ---

    public function testFetchNextReturnsEmptyArrayWhenNoJobs(): void
    {
        $this->mockDb->method('select')->willReturn([]);

        $result = $this->repo->fetchNext(5);
        $this->assertSame([], $result);
    }

    public function testFetchNextUpdatesStatusToProcessing(): void
    {
        $pendingJobs = [
            ['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => null],
            ['id' => 2, 'job_type' => 'test2', 'status' => 'pending', 'payload' => '{"x":1}'],
        ];
        $claimedJobs = [
            ['id' => 1, 'job_type' => 'test', 'status' => 'processing', 'payload' => null],
            ['id' => 2, 'job_type' => 'test2', 'status' => 'processing', 'payload' => '{"x":1}'],
        ];

        $this->mockDb->method('select')
            ->willReturnOnConsecutiveCalls($pendingJobs, $claimedJobs);

        $this->mockDb->expects($this->once())
            ->method('update')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) {
                    return $data['status'] === 'processing'
                        && isset($data['started_at']);
                }),
                $this->callback(function ($where) {
                    return $where['id'] === [1, 2]
                        && $where['status'] === 'pending';
                })
            );

        $result = $this->repo->fetchNext(5);

        $this->assertCount(2, $result);
        $this->assertSame('processing', $result[0]['status']);
        $this->assertSame('processing', $result[1]['status']);
    }

    public function testFetchNextDecodesPayloadJson(): void
    {
        $jobs = [
            ['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => '{"order_id":99}'],
        ];

        $this->mockDb->method('select')->willReturn($jobs);
        $this->mockDb->method('update');

        $result = $this->repo->fetchNext(1);

        $this->assertSame(['order_id' => 99], $result[0]['payload']);
    }

    // --- markCompleted ---

    public function testMarkCompletedUpdatesStatus(): void
    {
        $this->mockDb->expects($this->once())
            ->method('update')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) {
                    return $data['status'] === 'completed'
                        && isset($data['completed_at']);
                }),
                ['id' => 5]
            );

        $this->repo->markCompleted(5);
    }

    public function testMarkCompletedWithResult(): void
    {
        $result = ['synced' => true, 'count' => 3];

        $this->mockDb->expects($this->once())
            ->method('update')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) use ($result) {
                    return $data['result'] === json_encode($result);
                }),
                ['id' => 7]
            );

        $this->repo->markCompleted(7, $result);
    }

    // --- markFailed ---

    public function testMarkFailedWithRetriesLeft(): void
    {
        // Job with attempts < max_attempts → reschedule with backoff
        $this->mockDb->method('get')->willReturn([
            'max_attempts' => 10,
            'attempts' => 2,
        ]);

        $this->mockDb->expects($this->once())
            ->method('update')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) {
                    return $data['status'] === 'pending'
                        && isset($data['scheduled_at'])
                        && isset($data['last_error']);
                }),
                ['id' => 3]
            );

        $this->repo->markFailed(3, 'Connection timeout', 2);
    }

    public function testMarkFailedWhenMaxAttemptsReached(): void
    {
        // Job with attempts >= max_attempts → permanent failure
        $this->mockDb->method('get')->willReturn([
            'max_attempts' => 3,
            'attempts' => 3,
        ]);

        $this->mockDb->expects($this->once())
            ->method('update')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) {
                    return $data['status'] === 'failed'
                        && isset($data['completed_at']);
                }),
                ['id' => 4]
            );

        $this->repo->markFailed(4, 'Max retries exceeded');
    }

    public function testMarkFailedTruncatesErrorTo500Chars(): void
    {
        $this->mockDb->method('get')->willReturn([
            'max_attempts' => 10,
            'attempts' => 1,
        ]);

        $longError = str_repeat('x', 600);

        $this->mockDb->expects($this->once())
            ->method('update')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) {
                    return mb_strlen($data['last_error']) <= 500;
                }),
                ['id' => 1]
            );

        $this->repo->markFailed(1, $longError);
    }

    // --- hasPendingJob ---

    public function testHasPendingJobReturnsTrueWhenExists(): void
    {
        $this->mockDb->method('count')
            ->with('pp_cron_jobs', $this->callback(function ($where) {
                return $where['job_type'] === 'apilo_sync_payment'
                    && $where['status'] === ['pending', 'processing'];
            }))
            ->willReturn(1);

        $this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment'));
    }

    public function testHasPendingJobReturnsFalseWhenNone(): void
    {
        $this->mockDb->method('count')->willReturn(0);

        $this->assertFalse($this->repo->hasPendingJob('apilo_sync_payment'));
    }

    public function testHasPendingJobWithPayloadMatch(): void
    {
        $payload = ['order_id' => 42];

        $this->mockDb->expects($this->once())
            ->method('count')
            ->with('pp_cron_jobs', $this->callback(function ($where) use ($payload) {
                return $where['payload'] === json_encode($payload);
            }))
            ->willReturn(1);

        $this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment', $payload));
    }

    // --- cleanup ---

    public function testCleanupDeletesOldCompletedJobs(): void
    {
        $this->mockDb->expects($this->once())
            ->method('delete')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($where) {
                    return $where['status'] === ['completed', 'failed', 'cancelled']
                        && isset($where['updated_at[<]']);
                })
            );

        $this->repo->cleanup(30);
    }

    // --- recoverStuck ---

    public function testRecoverStuckResetsProcessingJobs(): void
    {
        $this->mockDb->expects($this->once())
            ->method('update')
            ->with(
                'pp_cron_jobs',
                $this->callback(function ($data) {
                    return $data['status'] === 'pending'
                        && $data['started_at'] === null;
                }),
                $this->callback(function ($where) {
                    return $where['status'] === 'processing'
                        && isset($where['started_at[<]']);
                })
            );

        $this->repo->recoverStuck(30);
    }

    // --- getDueSchedules ---

    public function testGetDueSchedulesReturnsEnabledSchedules(): void
    {
        $schedules = [
            ['id' => 1, 'job_type' => 'price_history', 'interval_seconds' => 86400],
        ];

        $this->mockDb->expects($this->once())
            ->method('select')
            ->with(
                'pp_cron_schedules',
                '*',
                $this->callback(function ($where) {
                    return $where['enabled'] === 1
                        && isset($where['OR']);
                })
            )
            ->willReturn($schedules);

        $result = $this->repo->getDueSchedules();
        $this->assertCount(1, $result);
    }

    // --- touchSchedule ---

    public function testTouchScheduleUpdatesTimestamps(): void
    {
        $this->mockDb->expects($this->once())
            ->method('update')
            ->with(
                'pp_cron_schedules',
                $this->callback(function ($data) {
                    return isset($data['last_run_at'])
                        && isset($data['next_run_at']);
                }),
                ['id' => 5]
            );

        $this->repo->touchSchedule(5, 3600);
    }
}
