<?php

namespace App\Services;

use App\Models\CategoryModel;
use App\Models\TaskModel;
use App\Models\TaskOccurrenceModel;
use Config\Tieasy;
use DateTimeImmutable;
use RuntimeException;
use Throwable;

class TaskService
{
    public const MSG_TITLE_REQUIRED   = 'Please enter a task title.';
    public const MSG_TITLE_LENGTH     = 'Task title must be 255 characters or fewer.';
    public const MSG_PRIORITY         = 'Please choose a priority.';
    public const MSG_CATEGORY         = 'Please choose a category.';
    public const MSG_NO_CATEGORIES    = 'Add a category before creating a task.';
    public const MSG_DATE             = 'Enter a valid due date.';
    public const MSG_TIME             = 'Enter a valid due time.';
    public const MSG_NOT_FOUND        = 'That task could not be found.';
    public const MSG_FAIL             = 'We could not save that task. Please try again.';
    public const MSG_CREATED          = 'Task saved.';
    public const MSG_UPDATED          = 'Task updated.';
    public const MSG_DELETED          = 'Task deleted.';
    public const MSG_RECURRING_DELETE = 'Recurring tasks must be deleted from an occurrence.';

    public const TITLE_MAX = 255;

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

    public function __construct(
        private readonly TaskModel $tasks = new TaskModel(),
        private readonly TaskOccurrenceModel $occurrences = new TaskOccurrenceModel(),
        private readonly CategoryModel $categories = new CategoryModel(),
        private readonly Tieasy $config = new Tieasy(),
        private readonly RecurrenceService $recurrence = new RecurrenceService(),
    ) {
    }

    /**
     * @return list<array<string, mixed>>
     */
    public function categoriesForUser(int $userId): array
    {
        return $this->categories
            ->where('user_id', $userId)
            ->where('is_active', 1)
            ->orderBy('name', 'ASC')
            ->findAll();
    }

    public function ownedCategory(int $userId, int $categoryId): ?array
    {
        $row = $this->categories->findOwned($userId, $categoryId);
        if ($row === null || (int) $row['is_active'] !== 1) {
            return null;
        }

        return $row;
    }

    public function findOwned(int $userId, int $taskId): ?array
    {
        return $this->tasks->findOwned($userId, $taskId);
    }

    /**
     * Soft-delete a non-recurring task and hard-delete its occurrence/rule rows.
     * Recurring series must use occurrence delete endpoints.
     *
     * @return array{ok: bool, message: string, status?: int}
     */
    public function deleteOwned(int $userId, int $taskId): array
    {
        $task = $this->findOwned($userId, $taskId);
        if ($task === null) {
            return ['ok' => false, 'message' => self::MSG_NOT_FOUND, 'status' => 404];
        }

        if ((int) $task['is_recurring'] === 1) {
            return ['ok' => false, 'message' => self::MSG_RECURRING_DELETE, 'status' => 400];
        }

        $result = $this->runTransaction(function () use ($taskId) {
            db_connect()->table('task_occurrences')->where('task_id', $taskId)->delete();
            db_connect()->table('task_recurrence_rules')->where('task_id', $taskId)->delete();
            if ($this->tasks->delete($taskId) === false) {
                throw new RuntimeException('Task delete failed');
            }

            return $this->tasks->withDeleted()->find($taskId);
        }, self::MSG_DELETED);

        if (! ($result['ok'] ?? false)) {
            $result['status'] = 500;
        }

        return $result;
    }

    public function recurrence(): RecurrenceService
    {
        return $this->recurrence;
    }

    public function sanitizePriority(?string $value): ?int
    {
        if ($value === null || $value === '') {
            return null;
        }
        if (! ctype_digit((string) $value)) {
            return null;
        }
        $priority = (int) $value;

        return array_key_exists($priority, $this->config->priorities) ? $priority : null;
    }

    /**
     * @param array<string, mixed> $post
     *
     * @return array<string, mixed>
     */
    public function collect(array $post): array
    {
        $recurring = $post['is_recurring'] ?? '0';

        $weekdays = $post['weekly_days'] ?? [];

        return [
            'title'                => is_string($post['title'] ?? null) ? (string) $post['title'] : '',
            'priority'             => (string) ($post['priority'] ?? ''),
            'category_id'          => (string) ($post['category_id'] ?? ''),
            'due_date'             => is_string($post['due_date'] ?? null) ? trim((string) $post['due_date']) : '',
            'due_time'             => is_string($post['due_time'] ?? null) ? trim((string) $post['due_time']) : '',
            'is_recurring'         => $recurring === '1' || $recurring === 1 || $recurring === true,
            'recurrence_type'      => (string) ($post['recurrence_type'] ?? 'daily'),
            'interval_value'       => trim((string) ($post['interval_value'] ?? '1')),
            'weekly_days'          => is_array($weekdays) ? $weekdays : [$weekdays],
            'monthly_type'         => (string) ($post['monthly_type'] ?? 'day_of_month'),
            'monthly_day'          => trim((string) ($post['monthly_day'] ?? '')),
            'monthly_week_position'=> (string) ($post['monthly_week_position'] ?? 'first'),
            'monthly_weekday'      => trim((string) ($post['monthly_weekday'] ?? '')),
            'recurrence_end_date'  => is_string($post['recurrence_end_date'] ?? null) ? trim((string) $post['recurrence_end_date']) : '',
        ];
    }

    /**
     * @param array<string, mixed> $raw
     *
     * @return array<string, string>
     */
    public function validate(int $userId, array $raw): array
    {
        $errors = [];

        $title = trim((string) $raw['title']);
        if ($title === '') {
            $errors['title'] = self::MSG_TITLE_REQUIRED;
        } elseif (mb_strlen($title) > self::TITLE_MAX) {
            $errors['title'] = self::MSG_TITLE_LENGTH;
        }

        $priority = $this->sanitizePriority((string) $raw['priority']);
        if ($priority === null) {
            $errors['priority'] = self::MSG_PRIORITY;
        }

        $categoryId = ctype_digit((string) $raw['category_id']) ? (int) $raw['category_id'] : 0;
        if ($this->categoriesForUser($userId) === []) {
            $errors['category_id'] = self::MSG_NO_CATEGORIES;
        } elseif ($categoryId < 1 || $this->ownedCategory($userId, $categoryId) === null) {
            $errors['category_id'] = self::MSG_CATEGORY;
        }

        $date = (string) $raw['due_date'];
        if ($date !== '' && $this->normalizeDate($date) === null) {
            $errors['due_date'] = self::MSG_DATE;
        }

        $time = (string) $raw['due_time'];
        if ($time !== '' && $this->normalizeTime($time) === null) {
            $errors['due_time'] = self::MSG_TIME;
        }

        if ($raw['is_recurring'] === true) {
            $errors = array_merge($errors, $this->recurrence->validateConfig($raw));
        }

        return $errors;
    }

    /**
     * @param array<string, mixed> $raw
     *
     * @return array{
     *     title: string,
     *     priority: int,
     *     category_id: int,
     *     due_date: string|null,
     *     due_time: string|null,
     *     is_recurring: int,
     *     recurrence_ends_at: string|null
     * }
     */
    public function normalized(array $raw): array
    {
        $dueDate = $this->normalizeDate((string) $raw['due_date']);
        $dueTime = $dueDate === null ? null : $this->normalizeTime((string) $raw['due_time']);
        $recurring = $raw['is_recurring'] === true;
        $endDate = $recurring ? $this->normalizeDate((string) ($raw['recurrence_end_date'] ?? '')) : null;

        return [
            'title'              => trim((string) $raw['title']),
            'priority'           => (int) $this->sanitizePriority((string) $raw['priority']),
            'category_id'        => (int) $raw['category_id'],
            'due_date'           => $dueDate,
            'due_time'           => $dueTime,
            'is_recurring'       => $recurring ? 1 : 0,
            'recurrence_ends_at' => $endDate,
        ];
    }

    /**
     * @param array<string, mixed> $raw
     *
     * @return array{ok: bool, message: string, errors?: array<string, string>, task?: array<string, mixed>}
     */
    public function create(int $userId, array $raw): array
    {
        $errors = $this->validate($userId, $raw);
        if ($errors !== []) {
            return $this->fail(reset($errors) ?: self::MSG_FAIL, $errors);
        }

        $data = $this->normalized($raw);

        return $this->runTransaction(function () use ($userId, $data, $raw) {
            $taskId = $this->tasks->skipValidation(true)->insert([
                'user_id'            => $userId,
                'category_id'        => $data['category_id'],
                'title'              => $data['title'],
                'priority'           => $data['priority'],
                'due_date'           => $data['due_date'],
                'due_time'           => $data['due_time'],
                'is_recurring'       => $data['is_recurring'],
                'recurrence_ends_at' => $data['recurrence_ends_at'],
            ], true);

            if (! is_numeric($taskId) || (int) $taskId < 1) {
                throw new RuntimeException('Task insert failed');
            }
            $taskId = (int) $taskId;

            if ($data['is_recurring'] === 1) {
                $this->recurrence->insertRule($taskId, $this->recurrence->normalizedRule($raw));
                if ($this->recurrence->generateForTask($taskId, 'initial') < 1) {
                    throw new RuntimeException('Recurrence generated no occurrences');
                }
            } else {
                $occurrenceId = $this->occurrences->insert([
                    'task_id'          => $taskId,
                    'occurrence_date'  => $data['due_date'],
                    'occurrence_time'  => $data['due_time'],
                    'status'           => 'pending',
                    'completed_at'     => null,
                    'is_deleted'       => 0,
                ], true);

                if (! is_numeric($occurrenceId) || (int) $occurrenceId < 1) {
                    throw new RuntimeException('Occurrence insert failed');
                }
            }

            return $this->tasks->find($taskId);
        }, self::MSG_CREATED);
    }

    /**
     * @param array<string, mixed> $raw
     *
     * @return array{ok: bool, message: string, errors?: array<string, string>, task?: array<string, mixed>}
     */
    public function update(int $userId, int $taskId, array $raw): array
    {
        $task = $this->findOwned($userId, $taskId);
        if ($task === null) {
            return $this->fail(self::MSG_NOT_FOUND);
        }

        $errors = $this->validate($userId, $raw);
        if ($errors !== []) {
            return $this->fail(reset($errors) ?: self::MSG_FAIL, $errors);
        }

        $data = $this->normalized($raw);
        $wasRecurring = (int) $task['is_recurring'] === 1;
        $nowRecurring = $data['is_recurring'] === 1;

        return $this->runTransaction(function () use ($taskId, $data, $raw, $wasRecurring, $nowRecurring) {
            if ($this->tasks->skipValidation(true)->update($taskId, [
                'category_id'        => $data['category_id'],
                'title'              => $data['title'],
                'priority'           => $data['priority'],
                'due_date'           => $data['due_date'],
                'due_time'           => $data['due_time'],
                'is_recurring'       => $data['is_recurring'],
                'recurrence_ends_at' => $data['recurrence_ends_at'],
            ]) === false) {
                throw new RuntimeException('Task update failed');
            }

            if (! $wasRecurring && ! $nowRecurring) {
                $this->syncNonRecurringOccurrence($taskId, $data['due_date'], $data['due_time']);
            } elseif (! $wasRecurring && $nowRecurring) {
                $this->recurrence->removeAllPending($taskId);
                $this->recurrence->upsertRule($taskId, $this->recurrence->normalizedRule($raw));
                $this->recurrence->generateForTask($taskId, 'initial');
            } elseif ($wasRecurring && ! $nowRecurring) {
                $this->recurrence->deleteRule($taskId);
                $this->recurrence->removeAllPending($taskId);
                $this->ensureNonRecurringOccurrence($taskId, $data['due_date'], $data['due_time']);
            } else {
                $this->recurrence->upsertRule($taskId, $this->recurrence->normalizedRule($raw));
                $this->recurrence->regenerateFuture($taskId);
            }

            return $this->tasks->find($taskId);
        }, self::MSG_UPDATED);
    }

    /**
     * @return array<string, mixed>|null
     */
    public function primaryOccurrence(int $taskId): ?array
    {
        $row = $this->occurrences
            ->where('task_id', $taskId)
            ->where('is_deleted', 0)
            ->orderBy('id', 'ASC')
            ->first();

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

    private function syncNonRecurringOccurrence(int $taskId, ?string $dueDate, ?string $dueTime): void
    {
        $occurrence = $this->primaryOccurrence($taskId);
        if ($occurrence === null) {
            $this->ensureNonRecurringOccurrence($taskId, $dueDate, $dueTime);

            return;
        }

        if ($this->occurrences->update($occurrence['id'], [
            'occurrence_date' => $dueDate,
            'occurrence_time' => $dueTime,
        ]) === false) {
            throw new RuntimeException('Occurrence update failed');
        }
    }

    private function ensureNonRecurringOccurrence(int $taskId, ?string $dueDate, ?string $dueTime): void
    {
        $builder = db_connect()->table('task_occurrences')->where('task_id', $taskId);
        if ($dueDate === null) {
            $existing = $builder->where('occurrence_date', null)->where('is_deleted', 0)->get()->getRowArray();
        } else {
            $existing = $builder->where('occurrence_date', $dueDate)->get()->getRowArray();
        }

        if ($existing !== null) {
            return;
        }

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

    /**
     * @param array<string, mixed> $task
     *
     * @return array<string, mixed>
     */
    public function formValues(array $task): array
    {
        $time = (string) ($task['due_time'] ?? '');
        if (strlen($time) >= 5) {
            $time = substr($time, 0, 5);
        }

        $values = [
            'title'                 => (string) ($task['title'] ?? ''),
            'priority'              => (string) ($task['priority'] ?? ''),
            'category_id'           => (string) ($task['category_id'] ?? ''),
            'due_date'              => (string) ($task['due_date'] ?? ''),
            'due_time'              => $time,
            'is_recurring'          => (int) ($task['is_recurring'] ?? 0) === 1 ? '1' : '0',
            'recurrence_type'       => 'daily',
            'interval_value'        => '1',
            'weekly_days'           => [],
            'monthly_type'          => 'day_of_month',
            'monthly_day'           => '',
            'monthly_week_position' => 'first',
            'monthly_weekday'       => '1',
            'recurrence_end_date'   => '',
        ];

        $rule = $this->recurrence->findRule((int) $task['id']);
        if ($rule !== null) {
            $end = (string) ($rule['end_date'] ?? '');
            $ruleTime = (string) ($rule['recurrence_time'] ?? '');
            if (strlen($ruleTime) >= 5) {
                $values['due_time'] = substr($ruleTime, 0, 5);
            }

            $values['is_recurring']          = '1';
            $values['recurrence_type']       = (string) $rule['recurrence_type'];
            $values['interval_value']        = (string) $rule['interval_value'];
            $values['weekly_days']           = array_map('strval', $this->recurrence->sanitizeWeekdays($rule['weekly_days'] ?? []));
            $values['monthly_type']          = (string) ($rule['monthly_type'] ?? 'day_of_month');
            $values['monthly_day']           = $rule['monthly_day'] === null ? '' : (string) $rule['monthly_day'];
            $values['monthly_week_position'] = (string) ($rule['monthly_week_position'] ?? 'first');
            $values['monthly_weekday']       = $rule['monthly_weekday'] === null ? '1' : (string) $rule['monthly_weekday'];
            $values['recurrence_end_date']   = $end;
            $values['due_date']              = (string) $rule['start_date'];
        }

        return $values;
    }

    public function normalizeDate(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;
    }

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

    /**
     * @param callable(): mixed $work
     *
     * @return array{ok: bool, message: string, task?: array<string, mixed>}
     */
    private function runTransaction(callable $work, string $success): array
    {
        $db = db_connect();
        $db->transException(true);
        $db->transStart();

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

            return $this->fail(self::MSG_FAIL);
        }

        if (! $db->transStatus()) {
            return $this->fail(self::MSG_FAIL);
        }

        $result = ['ok' => true, 'message' => $success];
        if (is_array($task)) {
            $result['task'] = $task;
        }

        return $result;
    }

    /**
     * @param array<string, string> $errors
     *
     * @return array{ok: bool, message: string, errors?: array<string, string>}
     */
    private function fail(string $message, array $errors = []): array
    {
        $result = ['ok' => false, 'message' => $message];
        if ($errors !== []) {
            $result['errors'] = $errors;
        }

        return $result;
    }
}
