<?php

namespace App\Services;

use App\Models\TaskModel;
use App\Models\TaskOccurrenceModel;
use App\Models\TaskRecurrenceModel;
use App\Support\AppClock;
use Config\Tieasy;
use DateTimeImmutable;
use RuntimeException;
use Throwable;

/**
 * Recurrence rules + occurrence generation for a single parent task.
 */
class RecurrenceService
{
    public const MSG_START           = 'Recurring tasks need a start date. Set Due Date.';
    public const MSG_TYPE            = 'Choose Daily, Weekly, or Monthly.';
    public const MSG_INTERVAL        = 'Enter how often this repeats (at least 1).';
    public const MSG_INTERVAL_MAX    = 'That repeat interval is too large.';
    public const MSG_WEEKDAYS        = 'Choose at least one weekday.';
    public const MSG_WEEKDAY         = 'Choose a valid weekday.';
    public const MSG_MONTHLY_TYPE    = 'Choose how this repeats each month.';
    public const MSG_MONTHLY_DAY     = 'Enter a day of the month between 1 and 31.';
    public const MSG_MONTHLY_POS     = 'Choose first, second, third, fourth, or last.';
    public const MSG_MONTHLY_WEEKDAY = 'Choose a weekday for the monthly pattern.';
    public const MSG_END             = 'End date cannot be before the start date.';
    public const MSG_NOT_FOUND       = 'That task could not be found.';
    public const MSG_FAIL            = 'We could not update that occurrence. Please try again.';
    public const MSG_DELETED         = 'That occurrence was deleted.';
    public const MSG_DELETED_FUTURE  = 'This and future occurrences were deleted.';
    public const MSG_COMPLETED       = 'Occurrence completed.';
    public const MSG_NOT_RECURRING   = 'That action is only available for recurring tasks.';

    public const TYPES      = ['daily', 'weekly', 'monthly'];
    public const MONTHLY    = ['day_of_month', 'weekday_position'];
    public const POSITIONS  = ['first', 'second', 'third', 'fourth', 'last'];
    public const MAX_DAILY  = 365;
    public const MAX_WEEKLY = 52;
    public const MAX_MONTHLY = 24;

    /** @var callable|null Invoked inside a mutation transaction before commit (tests). */
    public $beforeCommit = null;

    /** Y-m-d override so tests can freeze "today". */
    public ?string $todayOverride = null;

    public function __construct(
        private readonly TaskModel $tasks = new TaskModel(),
        private readonly TaskRecurrenceModel $rules = new TaskRecurrenceModel(),
        private readonly TaskOccurrenceModel $occurrences = new TaskOccurrenceModel(),
        private readonly Tieasy $config = new Tieasy(),
    ) {
    }

    public function today(): DateTimeImmutable
    {
        $value = $this->todayOverride ?? 'today';

        return AppClock::parse($value)->setTime(0, 0, 0);
    }

    public function horizonDays(): int
    {
        return max(1, (int) $this->config->occurrenceHorizonDays);
    }

    /**
     * @param array<string, mixed> $raw Collected task payload.
     *
     * @return array<string, string>
     */
    public function validateConfig(array $raw): array
    {
        $errors = [];
        $start  = trim((string) ($raw['due_date'] ?? ''));
        if ($start === '') {
            $errors['due_date'] = self::MSG_START;
        }

        $type = (string) ($raw['recurrence_type'] ?? '');
        if (! in_array($type, self::TYPES, true)) {
            $errors['recurrence_type'] = self::MSG_TYPE;
        }

        $intervalRaw = trim((string) ($raw['interval_value'] ?? ''));
        if ($intervalRaw === '' || ! ctype_digit($intervalRaw) || (int) $intervalRaw < 1) {
            $errors['interval_value'] = self::MSG_INTERVAL;
        } else {
            $max = match ($type) {
                'weekly'  => self::MAX_WEEKLY,
                'monthly' => self::MAX_MONTHLY,
                default   => self::MAX_DAILY,
            };
            if ((int) $intervalRaw > $max) {
                $errors['interval_value'] = self::MSG_INTERVAL_MAX;
            }
        }

        if ($type === 'weekly') {
            if ($this->hasInvalidWeekday($raw['weekly_days'] ?? [])) {
                $errors['weekly_days'] = self::MSG_WEEKDAY;
            } elseif ($this->sanitizeWeekdays($raw['weekly_days'] ?? []) === []) {
                $errors['weekly_days'] = self::MSG_WEEKDAYS;
            }
        }

        if ($type === 'monthly') {
            $monthlyType = (string) ($raw['monthly_type'] ?? '');
            if (! in_array($monthlyType, self::MONTHLY, true)) {
                $errors['monthly_type'] = self::MSG_MONTHLY_TYPE;
            } elseif ($monthlyType === 'day_of_month') {
                $day = trim((string) ($raw['monthly_day'] ?? ''));
                if ($day === '' || ! ctype_digit($day) || (int) $day < 1 || (int) $day > 31) {
                    $errors['monthly_day'] = self::MSG_MONTHLY_DAY;
                }
            } else {
                if (! in_array((string) ($raw['monthly_week_position'] ?? ''), self::POSITIONS, true)) {
                    $errors['monthly_week_position'] = self::MSG_MONTHLY_POS;
                }
                $weekday = trim((string) ($raw['monthly_weekday'] ?? ''));
                if ($weekday === '' || ! ctype_digit($weekday) || ! array_key_exists((int) $weekday, $this->config->weekdays)) {
                    $errors['monthly_weekday'] = self::MSG_MONTHLY_WEEKDAY;
                }
            }
        }

        $end = trim((string) ($raw['recurrence_end_date'] ?? ''));
        if ($end !== '') {
            if ($this->parseDate($end) === null) {
                $errors['recurrence_end_date'] = 'Enter a valid end date.';
            } elseif ($start !== '' && $end < $start) {
                $errors['recurrence_end_date'] = self::MSG_END;
            }
        }

        return $errors;
    }

    /**
     * @param array<string, mixed> $raw
     *
     * @return array<string, mixed>
     */
    public function normalizedRule(array $raw): array
    {
        $type     = (string) $raw['recurrence_type'];
        $interval = (int) $raw['interval_value'];
        $time     = $this->parseTime((string) ($raw['due_time'] ?? ''));
        $end      = $this->parseDate((string) ($raw['recurrence_end_date'] ?? ''));

        $rule = [
            'recurrence_type'        => $type,
            'interval_value'         => $interval,
            'weekly_days'            => [],
            'monthly_type'           => null,
            'monthly_day'            => null,
            'monthly_week_position'  => null,
            'monthly_weekday'        => null,
            'recurrence_time'        => $time,
            'start_date'             => (string) $raw['due_date'],
            'end_date'               => $end,
            'is_active'              => 1,
        ];

        if ($type === 'weekly') {
            $rule['weekly_days'] = $this->sanitizeWeekdays($raw['weekly_days'] ?? []);
        }

        if ($type === 'monthly') {
            $rule['monthly_type'] = (string) $raw['monthly_type'];
            if ($rule['monthly_type'] === 'day_of_month') {
                $rule['monthly_day'] = (int) $raw['monthly_day'];
            } else {
                $rule['monthly_week_position'] = (string) $raw['monthly_week_position'];
                $rule['monthly_weekday']       = (int) $raw['monthly_weekday'];
            }
        }

        return $rule;
    }

    /**
     * @return array<string, mixed>|null
     */
    public function findRule(int $taskId): ?array
    {
        $row = $this->rules->where('task_id', $taskId)->first();

        return $row === null ? null : $row;
    }

    /**
     * @param array<string, mixed> $normalized
     */
    public function insertRule(int $taskId, array $normalized): int
    {
        $id = $this->rules->insert(array_merge($normalized, ['task_id' => $taskId]), true);
        if (! is_numeric($id) || (int) $id < 1) {
            throw new RuntimeException('Recurrence rule insert failed');
        }

        return (int) $id;
    }

    /**
     * @param array<string, mixed> $normalized
     */
    public function upsertRule(int $taskId, array $normalized): void
    {
        $existing = $this->findRule($taskId);
        if ($existing === null) {
            $this->insertRule($taskId, $normalized);

            return;
        }

        if ($this->rules->update($existing['id'], $normalized) === false) {
            throw new RuntimeException('Recurrence rule update failed');
        }
    }

    public function deleteRule(int $taskId): void
    {
        db_connect()->table('task_recurrence_rules')->where('task_id', $taskId)->delete();
    }

    /**
     * @param array<string, mixed> $rule
     *
     * @return list<string> Y-m-d dates inclusive of $from/$to, clipped to the rule.
     */
    public function datesBetween(array $rule, string $from, string $to): array
    {
        $start = (string) $rule['start_date'];
        $end   = $rule['end_date'] ?? null;
        $end   = $end === '' ? null : $end;

        if ($from < $start) {
            $from = $start;
        }
        if ($end !== null && $to > $end) {
            $to = $end;
        }
        if ($from > $to) {
            return [];
        }

        $type = (string) $rule['recurrence_type'];

        return match ($type) {
            'daily'   => $this->dailyDates($rule, $from, $to),
            'weekly'  => $this->weeklyDates($rule, $from, $to),
            'monthly' => $this->monthlyDates($rule, $from, $to),
            default   => [],
        };
    }

    /**
     * @param 'initial'|'horizon' $window
     */
    public function generateForTask(int $taskId, string $window = 'initial'): int
    {
        $rule = $this->findRule($taskId);
        if ($rule === null || (int) $rule['is_active'] !== 1) {
            return 0;
        }

        $start  = AppClock::parse((string) $rule['start_date']);
        $today  = $this->today();
        $anchor = $window === 'horizon' ? ($start > $today ? $start : $today) : $start;
        $from   = $anchor->format('Y-m-d');
        $to     = $anchor->modify('+' . $this->horizonDays() . ' days')->format('Y-m-d');
        $dates  = $this->datesBetween($rule, $from, $to);
        $existing = $this->existingDates($taskId);
        $time     = $this->normalizeStoredTime($rule['recurrence_time'] ?? null);
        $created  = 0;

        foreach ($dates as $date) {
            if (isset($existing[$date])) {
                continue;
            }

            $id = $this->occurrences->insert([
                'task_id'         => $taskId,
                'occurrence_date' => $date,
                'occurrence_time' => $time,
                'status'          => 'pending',
                'completed_at'    => null,
                'is_deleted'      => 0,
            ], true);
            if (! is_numeric($id) || (int) $id < 1) {
                throw new RuntimeException('Occurrence insert failed');
            }
            $existing[$date] = true;
            $created++;
        }

        return $created;
    }

    public function extendHorizon(): int
    {
        $created = 0;
        $rules   = $this->rules->where('is_active', 1)->findAll();

        foreach ($rules as $rule) {
            $task = $this->tasks->find($rule['task_id']);
            if ($task === null || (int) $task['is_recurring'] !== 1) {
                continue;
            }

            $db = db_connect();
            $db->transException(true);
            $db->transStart();

            try {
                $created += $this->generateForTask((int) $task['id'], 'horizon');
                $db->transComplete();
            } catch (Throwable $exception) {
                if ($db->transStatus()) {
                    $db->transRollback();
                }
                log_message('error', 'Horizon extension failed for task {0}: {1}', [
                    $task['id'],
                    $exception->getMessage(),
                ]);
            }
        }

        return $created;
    }

    public function removePendingOnOrAfter(int $taskId, string $fromDate, bool $includeUndated = false): void
    {
        $builder = db_connect()->table('task_occurrences')
            ->where('task_id', $taskId)
            ->where('status', 'pending')
            ->where('is_deleted', 0);

        if ($includeUndated) {
            $builder->groupStart()
                ->where('occurrence_date >=', $fromDate)
                ->orWhere('occurrence_date', null)
                ->groupEnd();
        } else {
            $builder->where('occurrence_date >=', $fromDate);
        }

        $builder->delete();
    }

    public function removeAllPending(int $taskId): void
    {
        db_connect()->table('task_occurrences')
            ->where('task_id', $taskId)
            ->where('status', 'pending')
            ->where('is_deleted', 0)
            ->delete();
    }

    /**
     * Replace future pending occurrences to match the current rule. Completed and
     * intentionally deleted rows are left untouched.
     */
    public function regenerateFuture(int $taskId): int
    {
        $this->removePendingOnOrAfter($taskId, $this->today()->format('Y-m-d'), false);

        return $this->generateForTask($taskId, 'horizon');
    }

    /**
     * @return array<string, mixed>|null
     */
    public function ownedOccurrence(int $userId, int $occurrenceId): ?array
    {
        $row = db_connect()->table('task_occurrences as o')
            ->select('o.*, t.user_id, t.is_recurring, t.deleted_at as task_deleted_at')
            ->join('tasks as t', 't.id = o.task_id')
            ->where('o.id', $occurrenceId)
            ->where('t.user_id', $userId)
            ->where('t.deleted_at', null)
            ->get()
            ->getRowArray();

        return $row === null ? null : $row;
    }

    /**
     * @return array{ok: bool, message: string, status?: int}
     */
    public function completeOccurrence(int $userId, int $occurrenceId): array
    {
        $row = $this->ownedOccurrence($userId, $occurrenceId);
        if ($row === null || (int) $row['is_deleted'] === 1) {
            return ['ok' => false, 'message' => self::MSG_NOT_FOUND, 'status' => 404];
        }

        if (($row['status'] ?? '') === 'completed') {
            return ['ok' => true, 'message' => self::MSG_COMPLETED];
        }

        $ok = $this->occurrences->update($occurrenceId, [
            'status'       => 'completed',
            'completed_at' => date('Y-m-d H:i:s'),
        ]);

        if ($ok === false) {
            return ['ok' => false, 'message' => self::MSG_FAIL, 'status' => 500];
        }

        return ['ok' => true, 'message' => self::MSG_COMPLETED];
    }

    /**
     * @return array{ok: bool, message: string, status?: int}
     */
    public function deleteOccurrence(int $userId, int $occurrenceId): array
    {
        return $this->runMutation(function () use ($userId, $occurrenceId) {
            $row = $this->ownedOccurrence($userId, $occurrenceId);
            if ($row === null) {
                return ['ok' => false, 'message' => self::MSG_NOT_FOUND, 'status' => 404];
            }

            if ((int) $row['is_deleted'] === 1) {
                return ['ok' => true, 'message' => self::MSG_DELETED];
            }

            if ($this->occurrences->update($occurrenceId, ['is_deleted' => 1]) === false) {
                throw new RuntimeException('Occurrence delete failed');
            }

            return ['ok' => true, 'message' => self::MSG_DELETED];
        });
    }

    /**
     * @return array{ok: bool, message: string, status?: int}
     */
    public function deleteThisAndFuture(int $userId, int $occurrenceId): array
    {
        return $this->runMutation(function () use ($userId, $occurrenceId) {
            $row = $this->ownedOccurrence($userId, $occurrenceId);
            if ($row === null) {
                return ['ok' => false, 'message' => self::MSG_NOT_FOUND, 'status' => 404];
            }
            if ((int) $row['is_recurring'] !== 1) {
                return ['ok' => false, 'message' => self::MSG_NOT_RECURRING, 'status' => 400];
            }

            $cutoff = (string) ($row['occurrence_date'] ?? '');
            if ($cutoff === '') {
                return ['ok' => false, 'message' => self::MSG_FAIL, 'status' => 400];
            }

            db_connect()->table('task_occurrences')
                ->where('task_id', $row['task_id'])
                ->where('occurrence_date >=', $cutoff)
                ->update(['is_deleted' => 1]);

            $end = AppClock::parse($cutoff)->modify('-1 day')->format('Y-m-d');
            $rule = $this->findRule((int) $row['task_id']);
            if ($rule !== null) {
                $start      = (string) $rule['start_date'];
                $stillActive = $end >= $start ? 1 : 0;
                $this->rules->update($rule['id'], [
                    'end_date'  => $end >= $start ? $end : $start,
                    'is_active' => $stillActive,
                ]);
            }

            $this->tasks->skipValidation(true)->update((int) $row['task_id'], [
                'recurrence_ends_at' => $end,
            ]);

            return ['ok' => true, 'message' => self::MSG_DELETED_FUTURE];
        });
    }

    /**
     * @param array<int|string> $raw
     *
     * @return list<int>
     */
    public function sanitizeWeekdays(mixed $raw): array
    {
        if (! is_array($raw)) {
            $raw = $raw === null || $raw === '' ? [] : [$raw];
        }

        $days = [];
        foreach ($raw as $value) {
            if (! ctype_digit((string) $value) && ! is_int($value)) {
                continue;
            }
            $day = (int) $value;
            if (array_key_exists($day, $this->config->weekdays)) {
                $days[$day] = $day;
            }
        }
        sort($days);

        return array_values($days);
    }

    /**
     * @param array<string, mixed> $rule
     *
     * @return list<string>
     */
    private function dailyDates(array $rule, string $from, string $to): array
    {
        $interval = max(1, (int) $rule['interval_value']);
        $current  = AppClock::parse((string) $rule['start_date']);
        $fromDt   = AppClock::parse($from);
        $toDt     = AppClock::parse($to);
        $dates    = [];
        $guard    = 0;

        while ($current < $fromDt && $guard++ < 2000) {
            $current = $current->modify('+' . $interval . ' days');
        }

        $guard = 0;
        while ($current <= $toDt && $guard++ < 500) {
            $dates[] = $current->format('Y-m-d');
            $current = $current->modify('+' . $interval . ' days');
        }

        return $dates;
    }

    /**
     * @param array<string, mixed> $rule
     *
     * @return list<string>
     */
    private function weeklyDates(array $rule, string $from, string $to): array
    {
        $interval = max(1, (int) $rule['interval_value']);
        $days     = $this->sanitizeWeekdays($rule['weekly_days'] ?? []);
        if ($days === []) {
            return [];
        }

        $startMonday = $this->mondayOnOrBefore(AppClock::parse((string) $rule['start_date']));
        $current     = AppClock::parse($from);
        $toDt        = AppClock::parse($to);
        $start       = (string) $rule['start_date'];
        $wanted      = array_flip($days);
        $dates       = [];
        $guard       = 0;

        while ($current <= $toDt && $guard++ < 500) {
            $ymd = $current->format('Y-m-d');
            if ($ymd >= $start) {
                $weekday = (int) $current->format('N');
                if (isset($wanted[$weekday])) {
                    $weeks = (int) floor($startMonday->diff($this->mondayOnOrBefore($current))->days / 7);
                    if ($weeks >= 0 && $weeks % $interval === 0) {
                        $dates[] = $ymd;
                    }
                }
            }
            $current = $current->modify('+1 day');
        }

        return $dates;
    }

    /**
     * @param array<string, mixed> $rule
     *
     * @return list<string>
     */
    private function monthlyDates(array $rule, string $from, string $to): array
    {
        $interval   = max(1, (int) $rule['interval_value']);
        $start      = AppClock::parse((string) $rule['start_date']);
        $fromDt     = AppClock::parse($from);
        $toDt       = AppClock::parse($to);
        $startMonth = ((int) $start->format('Y')) * 12 + (int) $start->format('n');
        $year       = (int) $start->format('Y');
        $month      = (int) $start->format('n');
        $dates      = [];
        $guard      = 0;

        while ($guard++ < 120) {
            $first = AppClock::parse(sprintf('%04d-%02d-01', $year, $month));
            if ($first > $toDt && (int) $first->format('Ymd') > (int) $toDt->format('Ymd')) {
                break;
            }

            $cursorMonth = $year * 12 + $month;
            if (($cursorMonth - $startMonth) % $interval === 0) {
                $candidate = $this->monthlyCandidate($rule, $year, $month);
                if ($candidate !== null) {
                    $date = AppClock::parse($candidate);
                    if ($date >= $start && $date >= $fromDt && $date <= $toDt) {
                        $dates[] = $candidate;
                    }
                }
            }

            $month++;
            if ($month > 12) {
                $month = 1;
                $year++;
            }
            if ($year * 12 + $month > ((int) $toDt->format('Y')) * 12 + (int) $toDt->format('n')) {
                break;
            }
        }

        return $dates;
    }

    /**
     * Months that do not contain the requested day (e.g. 31 February) are skipped.
     */
    private function monthlyCandidate(array $rule, int $year, int $month): ?string
    {
        if (($rule['monthly_type'] ?? '') === 'weekday_position') {
            return $this->nthWeekdayOfMonth(
                $year,
                $month,
                (string) ($rule['monthly_week_position'] ?? ''),
                (int) ($rule['monthly_weekday'] ?? 0),
            );
        }

        $day = (int) ($rule['monthly_day'] ?? 0);
        if ($day < 1 || $day > 31 || ! checkdate($month, $day, $year)) {
            return null;
        }

        return sprintf('%04d-%02d-%02d', $year, $month, $day);
    }

    private function nthWeekdayOfMonth(int $year, int $month, string $position, int $weekday): ?string
    {
        if ($weekday < 1 || $weekday > 7 || ! in_array($position, self::POSITIONS, true)) {
            return null;
        }

        $first = AppClock::parse(sprintf('%04d-%02d-01', $year, $month));
        if ($position === 'last') {
            $date = $first->modify('last day of this month');
            while ((int) $date->format('N') !== $weekday) {
                $date = $date->modify('-1 day');
            }

            return $date->format('Y-m-d');
        }

        $nth   = ['first' => 1, 'second' => 2, 'third' => 3, 'fourth' => 4][$position];
        $delta = ($weekday - (int) $first->format('N') + 7) % 7;
        $date  = $first->modify('+' . $delta . ' days')->modify('+' . ($nth - 1) . ' weeks');
        if ((int) $date->format('n') !== $month) {
            return null;
        }

        return $date->format('Y-m-d');
    }

    private function mondayOnOrBefore(DateTimeImmutable $date): DateTimeImmutable
    {
        $n = (int) $date->format('N');

        return $date->modify('-' . ($n - 1) . ' days')->setTime(0, 0, 0);
    }

    /**
     * @return array<string, true>
     */
    private function existingDates(int $taskId): array
    {
        $rows = db_connect()->table('task_occurrences')
            ->select('occurrence_date')
            ->where('task_id', $taskId)
            ->where('occurrence_date IS NOT NULL', null, false)
            ->get()
            ->getResultArray();

        $map = [];
        foreach ($rows as $row) {
            $date = (string) $row['occurrence_date'];
            if ($date !== '') {
                $map[$date] = true;
            }
        }

        return $map;
    }

    private function normalizeStoredTime(mixed $value): ?string
    {
        if ($value === null || $value === '') {
            return null;
        }

        return $this->parseTime((string) $value);
    }

    private function parseDate(string $value): ?string
    {
        $value = trim($value);
        if ($value === '') {
            return null;
        }

        $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
        if ($date === false || $date->format('Y-m-d') !== $value) {
            return null;
        }

        $errors = DateTimeImmutable::getLastErrors();
        if (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)) {
            return null;
        }

        return $value;
    }

    private function parseTime(string $value): ?string
    {
        $value = trim($value);
        if ($value === '') {
            return null;
        }

        if (preg_match('/^([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$/', $value, $match) !== 1) {
            return null;
        }

        $seconds = $match[3] ?? '00';

        return sprintf('%s:%s:%s', $match[1], $match[2], $seconds === '' ? '00' : $seconds);
    }

    private function hasInvalidWeekday(mixed $raw): bool
    {
        if (! is_array($raw)) {
            $raw = $raw === null || $raw === '' ? [] : [$raw];
        }

        foreach ($raw as $value) {
            if ($value === '' || $value === null) {
                continue;
            }
            if (! ctype_digit((string) $value) && ! is_int($value)) {
                return true;
            }
            if (! array_key_exists((int) $value, $this->config->weekdays)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param callable(): array{ok: bool, message: string, status?: int} $work
     *
     * @return array{ok: bool, message: string, status?: int}
     */
    private function runMutation(callable $work): array
    {
        $db = db_connect();
        $db->transException(true);
        $db->transStart();

        try {
            $result = $work();
            if (is_callable($this->beforeCommit)) {
                ($this->beforeCommit)();
            }
            $db->transComplete();
        } catch (Throwable $exception) {
            if ($db->transStatus()) {
                $db->transRollback();
            }
            log_message('error', 'Occurrence mutation failed: {0}', [$exception->getMessage()]);

            return ['ok' => false, 'message' => self::MSG_FAIL, 'status' => 500];
        }

        if (! $db->transStatus()) {
            return ['ok' => false, 'message' => self::MSG_FAIL, 'status' => 500];
        }

        return $result;
    }
}
