<?php

use App\Services\RecurrenceService;
use App\Services\TaskService;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use Tests\Support\Auth\AuthTestTrait;
use Tests\Support\Categories\CategoryTestTrait;

/**
 * @internal
 */
final class RecurrenceServiceTest extends CIUnitTestCase
{
    use DatabaseTestTrait;
    use AuthTestTrait;
    use CategoryTestTrait;

    protected $namespace = 'App';
    protected $refresh   = true;
    protected $migrate   = true;
    protected $seed      = '';

    private TaskService $tasks;
    private RecurrenceService $recurrence;

    protected function setUp(): void
    {
        parent::setUp();
        $this->tasks      = new TaskService();
        $this->recurrence = new RecurrenceService();
        $this->recurrence->todayOverride = '2026-08-29';
        $this->tasks->recurrence()->todayOverride = '2026-08-29';
    }

    /**
     * @param array<string, mixed> $override
     *
     * @return array<string, mixed>
     */
    private function payload(int $categoryId, array $override = []): array
    {
        return $this->tasks->collect(array_merge([
            'title'                 => 'Repeat me',
            'priority'              => '1',
            'category_id'           => (string) $categoryId,
            'due_date'              => '2026-09-01',
            'due_time'              => '',
            'is_recurring'          => '1',
            'recurrence_type'       => 'daily',
            'interval_value'        => '1',
            'weekly_days'           => [],
            'monthly_type'          => 'day_of_month',
            'monthly_day'           => '1',
            'monthly_week_position' => 'first',
            'monthly_weekday'       => '3',
        ], $override));
    }

    /**
     * @return list<string>
     */
    private function dates(int $taskId, bool $includeDeleted = false): array
    {
        $builder = db_connect()->table('task_occurrences')->where('task_id', $taskId);
        if (! $includeDeleted) {
            $builder->where('is_deleted', 0);
        }

        $rows = $builder->orderBy('occurrence_date', 'ASC')->orderBy('id', 'ASC')->get()->getResultArray();

        return array_map(static fn (array $row): string => (string) $row['occurrence_date'], $rows);
    }

    public function testDailyEveryOneAndTwoDaysWithStartAndOptionalTime(): void
    {
        $user = $this->createUser('rec-daily@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');

        $everyOne = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id'], [
            'due_time' => '09:15',
        ]));
        $this->assertTrue($everyOne['ok']);
        $taskId = (int) $everyOne['task']['id'];
        $dates  = $this->dates($taskId);
        $this->assertSame('2026-09-01', $dates[0]);
        $this->assertSame('2026-11-30', $dates[array_key_last($dates)]);
        $this->assertCount(91, $dates);
        $this->assertSame(1, db_connect()->table('task_recurrence_rules')->where('task_id', $taskId)->countAllResults());
        $this->assertSame('09:15:00', db_connect()->table('task_occurrences')->where('task_id', $taskId)->get()->getRowArray()['occurrence_time']);

        $everyTwo = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id'], [
            'title'          => 'Every other day',
            'interval_value' => '2',
        ]));
        $twoId = (int) $everyTwo['task']['id'];
        $two   = $this->dates($twoId);
        $this->assertSame(['2026-09-01', '2026-09-03', '2026-09-05'], array_slice($two, 0, 3));
        $this->assertCount(46, $two);
        $this->assertNull(db_connect()->table('task_occurrences')->where('task_id', $twoId)->get()->getRowArray()['occurrence_time']);
    }

    public function testDailyDatesBetweenMatchesExamples(): void
    {
        $dates = $this->recurrence->datesBetween([
            'recurrence_type' => 'daily',
            'interval_value'  => 2,
            'start_date'      => '2026-09-01',
            'end_date'        => null,
        ], '2026-09-01', '2026-09-05');
        $this->assertSame(['2026-09-01', '2026-09-03', '2026-09-05'], $dates);
    }

    public function testWeeklyEveryOneAndTwoWeeksWithWeekdayMapping(): void
    {
        $user = $this->createUser('rec-weekly@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');

        $oneDay = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id'], [
            'title'           => 'Wednesdays',
            'recurrence_type' => 'weekly',
            'interval_value'  => '1',
            'weekly_days'     => ['3'],
        ]));
        $this->assertTrue($oneDay['ok']);
        $wed = $this->dates((int) $oneDay['task']['id']);
        $this->assertSame('2026-09-02', $wed[0]);
        foreach ($wed as $date) {
            $this->assertSame(3, (int) (new \DateTimeImmutable($date))->format('N'));
        }

        $multi = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id'], [
            'title'           => 'Mon Wed fortnight',
            'recurrence_type' => 'weekly',
            'interval_value'  => '2',
            'weekly_days'     => ['1', '3'],
        ]));
        $dates = $this->dates((int) $multi['task']['id']);
        $this->assertContains('2026-09-02', $dates);
        $this->assertContains('2026-09-14', $dates);
        $this->assertContains('2026-09-16', $dates);
        $this->assertNotContains('2026-09-07', $dates);
        $this->assertNotContains('2026-09-09', $dates);
    }

    public function testWeeklyMissingOrInvalidWeekdayRejected(): void
    {
        $user = $this->createUser('rec-week-bad@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');

        $missing = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id'], [
            'recurrence_type' => 'weekly',
            'weekly_days'     => [],
        ]));
        $this->assertFalse($missing['ok']);
        $this->assertSame(RecurrenceService::MSG_WEEKDAYS, $missing['errors']['weekly_days']);

        $invalid = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id'], [
            'recurrence_type' => 'weekly',
            'weekly_days'     => ['9'],
        ]));
        $this->assertFalse($invalid['ok']);
        $this->assertSame(RecurrenceService::MSG_WEEKDAY, $invalid['errors']['weekly_days']);
    }

    public function testMonthlyDayOfMonthSkipsShortMonths(): void
    {
        $user = $this->createUser('rec-month-day@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');

        $created = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id'], [
            'due_date'        => '2026-01-31',
            'recurrence_type' => 'monthly',
            'monthly_type'    => 'day_of_month',
            'monthly_day'     => '31',
        ]));
        $this->assertTrue($created['ok']);
        $dates = $this->dates((int) $created['task']['id']);
        $this->assertContains('2026-01-31', $dates);
        $this->assertContains('2026-03-31', $dates);
        $this->assertNotContains('2026-02-28', $dates);
        $this->assertNotContains('2026-02-31', $dates);

        $everyTwo = $this->recurrence->datesBetween([
            'recurrence_type' => 'monthly',
            'interval_value'  => 2,
            'start_date'      => '2026-09-01',
            'end_date'        => null,
            'monthly_type'    => 'day_of_month',
            'monthly_day'     => 15,
        ], '2026-09-01', '2027-01-31');
        $this->assertSame(['2026-09-15', '2026-11-15', '2027-01-15'], $everyTwo);
    }

    public function testMonthlyWeekdayPositions(): void
    {
        $firstWed = $this->recurrence->datesBetween([
            'recurrence_type'        => 'monthly',
            'interval_value'         => 1,
            'start_date'             => '2026-09-01',
            'end_date'               => null,
            'monthly_type'           => 'weekday_position',
            'monthly_week_position'  => 'first',
            'monthly_weekday'        => 3,
        ], '2026-09-01', '2026-11-30');
        $this->assertSame(['2026-09-02', '2026-10-07', '2026-11-04'], $firstWed);

        $lastFri = $this->recurrence->datesBetween([
            'recurrence_type'        => 'monthly',
            'interval_value'         => 1,
            'start_date'             => '2026-09-01',
            'end_date'               => null,
            'monthly_type'           => 'weekday_position',
            'monthly_week_position'  => 'last',
            'monthly_weekday'        => 5,
        ], '2026-09-01', '2026-11-30');
        $this->assertSame(['2026-09-25', '2026-10-30', '2026-11-27'], $lastFri);

        $secondMon = $this->recurrence->datesBetween([
            'recurrence_type'        => 'monthly',
            'interval_value'         => 2,
            'start_date'             => '2026-09-01',
            'end_date'               => null,
            'monthly_type'           => 'weekday_position',
            'monthly_week_position'  => 'second',
            'monthly_weekday'        => 1,
        ], '2026-09-01', '2027-01-31');
        $this->assertSame(['2026-09-14', '2026-11-09', '2027-01-11'], $secondMon);
    }

    public function testRecurrenceValidationRules(): void
    {
        $user = $this->createUser('rec-val@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');
        $uid  = (int) $user['id'];
        $cid  = (int) $cat['id'];

        $this->assertSame(RecurrenceService::MSG_START, $this->tasks->create($uid, $this->payload($cid, ['due_date' => '']))['errors']['due_date']);
        $this->assertSame(RecurrenceService::MSG_TYPE, $this->tasks->create($uid, $this->payload($cid, ['recurrence_type' => 'yearly']))['errors']['recurrence_type']);
        $this->assertSame(RecurrenceService::MSG_INTERVAL, $this->tasks->create($uid, $this->payload($cid, ['interval_value' => '0']))['errors']['interval_value']);
        $this->assertSame(RecurrenceService::MSG_INTERVAL_MAX, $this->tasks->create($uid, $this->payload($cid, ['interval_value' => '366']))['errors']['interval_value']);
        $this->assertSame(RecurrenceService::MSG_MONTHLY_TYPE, $this->tasks->create($uid, $this->payload($cid, [
            'recurrence_type' => 'monthly',
            'monthly_type'    => 'nope',
        ]))['errors']['monthly_type']);
        $this->assertSame(RecurrenceService::MSG_MONTHLY_DAY, $this->tasks->create($uid, $this->payload($cid, [
            'recurrence_type' => 'monthly',
            'monthly_type'    => 'day_of_month',
            'monthly_day'     => '32',
        ]))['errors']['monthly_day']);
        $this->assertSame(RecurrenceService::MSG_END, $this->tasks->create($uid, $this->payload($cid, [
            'recurrence_end_date' => '2026-08-01',
        ]))['errors']['recurrence_end_date']);
        $this->assertSame(0, db_connect()->table('tasks')->where('user_id', $uid)->countAllResults());
    }

    public function testEndDateStopsGenerationAndRepeatedGenerateDoesNotDuplicate(): void
    {
        $user = $this->createUser('rec-end@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');
        $created = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id'], [
            'recurrence_end_date' => '2026-09-05',
        ]));
        $taskId = (int) $created['task']['id'];
        $this->assertSame(['2026-09-01', '2026-09-02', '2026-09-03', '2026-09-04', '2026-09-05'], $this->dates($taskId));

        $this->assertSame(0, $this->recurrence->generateForTask($taskId, 'initial'));
        $this->assertCount(5, $this->dates($taskId));
    }

    public function testHorizonExtensionPreservesCompletedAndDeleted(): void
    {
        $user = $this->createUser('rec-horizon@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');
        $created = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id']));
        $taskId = (int) $created['task']['id'];

        $sep10 = db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-10')->get()->getRowArray();
        $sep11 = db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-11')->get()->getRowArray();
        $this->assertNotNull($sep10);
        $this->recurrence->completeOccurrence((int) $user['id'], (int) $sep10['id']);
        $this->recurrence->deleteOccurrence((int) $user['id'], (int) $sep11['id']);

        $this->recurrence->todayOverride = '2026-12-01';
        $added = $this->recurrence->extendHorizon();
        $this->assertGreaterThan(0, $added);
        $this->assertContains('2026-12-01', $this->dates($taskId));
        $this->assertContains('2026-12-30', $this->dates($taskId));

        $sep10b = db_connect()->table('task_occurrences')->where('id', $sep10['id'])->get()->getRowArray();
        $sep11b = db_connect()->table('task_occurrences')->where('id', $sep11['id'])->get()->getRowArray();
        $this->assertSame('completed', $sep10b['status']);
        $this->assertSame(1, (int) $sep11b['is_deleted']);
        $this->assertSame(1, db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-11')->countAllResults());
        $this->assertSame(0, $this->recurrence->extendHorizon());
    }

    public function testEditRegeneratesFutureAndKeepsCompletedHistory(): void
    {
        $user = $this->createUser('rec-edit@example.com', 1);
        $one  = $this->createCategory((int) $user['id'], 'Home');
        $two  = $this->createCategory((int) $user['id'], 'Office');
        $created = $this->tasks->create((int) $user['id'], $this->payload((int) $one['id']));
        $taskId = (int) $created['task']['id'];
        $sep01 = db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-01')->get()->getRowArray();
        $this->recurrence->completeOccurrence((int) $user['id'], (int) $sep01['id']);

        $updated = $this->tasks->update((int) $user['id'], $taskId, $this->payload((int) $two['id'], [
            'title'           => 'Weekly now',
            'priority'        => '2',
            'recurrence_type' => 'weekly',
            'weekly_days'     => ['3'],
        ]));
        $this->assertTrue($updated['ok']);
        $this->assertSame('Weekly now', $updated['task']['title']);
        $this->assertSame(2, (int) $updated['task']['priority']);
        $this->assertSame((int) $two['id'], (int) $updated['task']['category_id']);

        $sep01b = db_connect()->table('task_occurrences')->where('id', $sep01['id'])->get()->getRowArray();
        $this->assertSame('completed', $sep01b['status']);
        $this->assertSame(0, (int) $sep01b['is_deleted']);

        $pending = $this->dates($taskId);
        $this->assertContains('2026-09-01', $pending);
        $this->assertContains('2026-09-02', $pending);
        $this->assertNotContains('2026-09-03', $pending);
        $this->assertSame(count($pending), count(array_unique($pending)));
        $this->assertSame(1, db_connect()->table('task_recurrence_rules')->where('task_id', $taskId)->countAllResults());
    }

    public function testNonRecurringToRecurringAndBack(): void
    {
        $user = $this->createUser('rec-convert@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');
        $plain = $this->tasks->create((int) $user['id'], $this->tasks->collect([
            'title'       => 'Once',
            'priority'    => '1',
            'category_id' => (string) $cat['id'],
            'due_date'    => '2026-09-01',
            'due_time'    => '10:00',
        ]));
        $taskId = (int) $plain['task']['id'];
        $first  = $this->tasks->primaryOccurrence($taskId);
        $this->recurrence->completeOccurrence((int) $user['id'], (int) $first['id']);

        $toRecurring = $this->tasks->update((int) $user['id'], $taskId, $this->payload((int) $cat['id'], [
            'title' => 'Now repeating',
        ]));
        $this->assertTrue($toRecurring['ok']);
        $this->assertSame(1, (int) $toRecurring['task']['is_recurring']);
        $this->assertSame(1, db_connect()->table('task_recurrence_rules')->where('task_id', $taskId)->countAllResults());
        $this->assertGreaterThan(1, count($this->dates($taskId)));
        $kept = db_connect()->table('task_occurrences')->where('id', $first['id'])->get()->getRowArray();
        $this->assertSame('completed', $kept['status']);

        $back = $this->tasks->update((int) $user['id'], $taskId, $this->tasks->collect([
            'title'       => 'Single again',
            'priority'    => '1',
            'category_id' => (string) $cat['id'],
            'due_date'    => '2026-09-20',
            'due_time'    => '11:00',
        ]));
        $this->assertTrue($back['ok']);
        $this->assertSame(0, (int) $back['task']['is_recurring']);
        $this->assertSame(0, db_connect()->table('task_recurrence_rules')->where('task_id', $taskId)->countAllResults());
        $this->assertSame('completed', db_connect()->table('task_occurrences')->where('id', $first['id'])->get()->getRowArray()['status']);
        $pending = db_connect()->table('task_occurrences')
            ->where('task_id', $taskId)
            ->where('is_deleted', 0)
            ->where('status', 'pending')
            ->get()
            ->getResultArray();
        $this->assertCount(1, $pending);
        $this->assertSame('2026-09-20', $pending[0]['occurrence_date']);
    }

    public function testDeleteThisOccurrenceDoesNotStopSeriesOrRegenerate(): void
    {
        $user = $this->createUser('rec-del-one@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');
        $created = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id']));
        $taskId = (int) $created['task']['id'];
        $sep03 = db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-03')->get()->getRowArray();

        $result = $this->recurrence->deleteOccurrence((int) $user['id'], (int) $sep03['id']);
        $this->assertTrue($result['ok']);
        $this->assertNotContains('2026-09-03', $this->dates($taskId));
        $this->assertContains('2026-09-04', $this->dates($taskId));
        $this->assertSame(1, (int) $this->recurrence->findRule($taskId)['is_active']);

        $this->assertSame(0, $this->recurrence->generateForTask($taskId, 'initial'));
        $this->assertNotContains('2026-09-03', $this->dates($taskId));
        $this->assertSame(1, db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-03')->where('is_deleted', 1)->countAllResults());
    }

    public function testDeleteThisAndFutureStopsGenerationAndKeepsPriorHistory(): void
    {
        $user = $this->createUser('rec-del-future@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');
        $created = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id']));
        $taskId = (int) $created['task']['id'];
        $sep01 = db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-01')->get()->getRowArray();
        $this->recurrence->completeOccurrence((int) $user['id'], (int) $sep01['id']);
        $sep03 = db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-03')->get()->getRowArray();

        $result = $this->recurrence->deleteThisAndFuture((int) $user['id'], (int) $sep03['id']);
        $this->assertTrue($result['ok']);

        $visible = $this->dates($taskId);
        $this->assertContains('2026-09-01', $visible);
        $this->assertContains('2026-09-02', $visible);
        $this->assertNotContains('2026-09-03', $visible);
        $this->assertNotContains('2026-09-04', $visible);
        $this->assertSame('completed', db_connect()->table('task_occurrences')->where('id', $sep01['id'])->get()->getRowArray()['status']);

        $rule = $this->recurrence->findRule($taskId);
        $this->assertSame('2026-09-02', $rule['end_date']);
        $this->recurrence->todayOverride = '2026-12-01';
        $this->assertSame(0, $this->recurrence->extendHorizon());
        $this->assertNotContains('2026-12-01', $this->dates($taskId));
    }

    public function testCompleteOneOccurrenceDoesNotCompleteOthersOrMaster(): void
    {
        $user = $this->createUser('rec-complete@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');
        $created = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id']));
        $taskId = (int) $created['task']['id'];
        $sep01 = db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-01')->get()->getRowArray();
        $sep02 = db_connect()->table('task_occurrences')->where('task_id', $taskId)->where('occurrence_date', '2026-09-02')->get()->getRowArray();

        $this->assertTrue($this->recurrence->completeOccurrence((int) $user['id'], (int) $sep01['id'])['ok']);
        $this->assertSame('completed', db_connect()->table('task_occurrences')->where('id', $sep01['id'])->get()->getRowArray()['status']);
        $this->assertSame('pending', db_connect()->table('task_occurrences')->where('id', $sep02['id'])->get()->getRowArray()['status']);
        $this->assertSame(1, (int) db_connect()->table('tasks')->where('id', $taskId)->get()->getRowArray()['is_recurring']);
        $this->assertSame(1, (int) $this->recurrence->findRule($taskId)['is_active']);
    }

    public function testCannotMutateAnotherUsersOccurrence(): void
    {
        $alice = $this->createUser('rec-own-a@example.com', 1);
        $bob   = $this->createUser('rec-own-b@example.com', 1);
        $aliceCat = $this->createCategory((int) $alice['id'], 'Alice');
        $bobCat   = $this->createCategory((int) $bob['id'], 'Bob');
        $bobs = $this->tasks->create((int) $bob['id'], $this->payload((int) $bobCat['id'], ['title' => 'Secret series']));
        $occ  = db_connect()->table('task_occurrences')->where('task_id', $bobs['task']['id'])->get()->getRowArray();

        $del = $this->recurrence->deleteOccurrence((int) $alice['id'], (int) $occ['id']);
        $this->assertFalse($del['ok']);
        $this->assertSame(RecurrenceService::MSG_NOT_FOUND, $del['message']);
        $this->assertArrayNotHasKey('title', $del);

        $future = $this->recurrence->deleteThisAndFuture((int) $alice['id'], (int) $occ['id']);
        $this->assertFalse($future['ok']);
        $this->assertSame(RecurrenceService::MSG_NOT_FOUND, $future['message']);

        $edit = $this->tasks->update((int) $alice['id'], (int) $bobs['task']['id'], $this->payload((int) $aliceCat['id'], [
            'title' => 'Hacked',
        ]));
        $this->assertFalse($edit['ok']);
        $this->seeInDatabase('tasks', ['id' => $bobs['task']['id'], 'title' => 'Secret series']);
        $this->assertSame(0, (int) db_connect()->table('task_occurrences')->where('id', $occ['id'])->get()->getRowArray()['is_deleted']);
    }

    public function testCreateRollsBackWhenGenerationFails(): void
    {
        $user = $this->createUser('rec-tx@example.com', 1);
        $cat  = $this->createCategory((int) $user['id'], 'Home');
        $this->tasks->beforeCommit = static function (): void {
            throw new \RuntimeException('forced');
        };
        $result = $this->tasks->create((int) $user['id'], $this->payload((int) $cat['id']));
        $this->assertFalse($result['ok']);
        $this->assertSame(0, db_connect()->table('tasks')->where('user_id', $user['id'])->countAllResults());
        $this->assertSame(0, db_connect()->table('task_recurrence_rules')->countAllResults());
        $this->assertSame(0, db_connect()->table('task_occurrences')->countAllResults());
    }
}
