<?php

namespace App\Services;

use App\Support\AppClock;
use Config\Tieasy;
use DateTimeImmutable;

/**
 * Dashboard and priority-list queries. Recurring and non-recurring tasks are
 * listed through task_occurrences, scoped to the authenticated user.
 *
 * Ordering within each priority:
 * 1. Dated occurrences first (NULL occurrence_date last — All Dates only)
 * 2. occurrence_date ascending
 * 3. Timed occurrences before untimed on the same date (NULL time last)
 * 4. occurrence_time ascending
 * 5. occurrence id ascending
 */
class DashboardService
{
    public const SESSION_KEY = 'dashboard_filters';
    public const RETURN_KEY  = 'list_return';

    public const DATE_ALL      = 'all';
    public const DATE_TODAY    = 'today';
    public const DATE_TOMORROW = 'tomorrow';
    public const DATE_WEEK     = 'week';
    public const DATE_NEXT7    = 'next7';
    public const DATE_MONTH    = 'month';
    public const DATE_CUSTOM   = 'custom';

    public const STATUS_ALL       = 'all';
    public const STATUS_PENDING   = 'pending';
    public const STATUS_COMPLETED = 'completed';

    public const MSG_CUSTOM_INVALID = 'Enter valid From and To dates.';
    public const MSG_CUSTOM_ORDER   = 'To date cannot be before From date.';
    public const MSG_SEARCH_EMPTY   = 'Enter something to search.';
    public const SEARCH_QUERY_MAX   = 255;

    public const DATE_MODES = [
        self::DATE_ALL,
        self::DATE_TODAY,
        self::DATE_TOMORROW,
        self::DATE_WEEK,
        self::DATE_NEXT7,
        self::DATE_MONTH,
        self::DATE_CUSTOM,
    ];

    public const STATUS_MODES = [
        self::STATUS_ALL,
        self::STATUS_PENDING,
        self::STATUS_COMPLETED,
    ];

    /** @var string|null Y-m-d override for tests. */
    public ?string $todayOverride = null;

    public function __construct(
        private readonly TaskService $tasks = new TaskService(),
        private readonly Tieasy $config = new Tieasy(),
    ) {
    }

    public function today(): DateTimeImmutable
    {
        if ($this->todayOverride !== null && $this->todayOverride !== '') {
            return AppClock::parse($this->todayOverride)->setTime(0, 0, 0);
        }

        return AppClock::today();
    }

    /**
     * @return array{date: string, status: string, category: string, from: string, to: string}
     */
    public function defaults(): array
    {
        return [
            'date'     => self::DATE_TODAY,
            'status'   => self::STATUS_PENDING,
            'category' => 'all',
            'from'     => '',
            'to'       => '',
        ];
    }

    /**
     * Merge query-string filters over session (or defaults). Invalid or foreign
     * category IDs fall back to All Categories. Invalid custom ranges keep the
     * previous date filter and surface an error.
     *
     * @param array<string, mixed>      $query
     * @param array<string, mixed>|null $stored
     *
     * @return array{filters: array{date: string, status: string, category: string, from: string, to: string}, error: string|null}
     */
    public function resolve(int $userId, array $query, ?array $stored): array
    {
        $base = $this->sanitizeStored($stored);
        $base['category'] = $this->sanitizeCategory($userId, $base['category']);
        $error = null;

        $hasQuery = $this->queryHasFilterKeys($query);
        if (! $hasQuery) {
            return ['filters' => $base, 'error' => null];
        }

        $date = $this->sanitizeDateMode($query['date'] ?? null);
        if ($date !== null) {
            $base['date'] = $date;
        }

        $status = $this->sanitizeStatusMode($query['status'] ?? null);
        if ($status !== null) {
            $base['status'] = $status;
        }

        if (array_key_exists('category', $query)) {
            $base['category'] = $this->sanitizeCategory($userId, $query['category']);
        }

        if ($base['date'] === self::DATE_CUSTOM) {
            $from = $this->tasks->normalizeDate(trim((string) ($query['from'] ?? $base['from'])));
            $to   = $this->tasks->normalizeDate(trim((string) ($query['to'] ?? $base['to'])));
            if ($from === null || $to === null) {
                $error        = self::MSG_CUSTOM_INVALID;
                $base['date'] = $this->sanitizeDateMode($stored['date'] ?? null) ?? self::DATE_TODAY;
                if ($base['date'] === self::DATE_CUSTOM) {
                    $base['date'] = self::DATE_TODAY;
                }
                $base['from'] = '';
                $base['to']   = '';
            } elseif ($to < $from) {
                $error        = self::MSG_CUSTOM_ORDER;
                $base['date'] = $this->sanitizeDateMode($stored['date'] ?? null) ?? self::DATE_TODAY;
                if ($base['date'] === self::DATE_CUSTOM) {
                    $base['date'] = self::DATE_TODAY;
                }
                $base['from'] = '';
                $base['to']   = '';
            } else {
                $base['from'] = $from;
                $base['to']   = $to;
            }
        } else {
            $base['from'] = '';
            $base['to']   = '';
        }

        return ['filters' => $base, 'error' => $error];
    }

    /**
     * @param array<string, mixed> $filters
     *
     * @return list<array<string, mixed>>
     */
    public function occurrences(int $userId, array $filters, ?int $priority = null, ?string $titleQuery = null): array
    {
        $builder = db_connect()->table('task_occurrences as o')
            ->select('o.id as occurrence_id, o.occurrence_date, o.occurrence_time, o.status, o.completed_at, t.id as task_id, t.title, t.priority, t.is_recurring, c.id as category_id, c.name as category_name')
            ->join('tasks as t', 't.id = o.task_id')
            ->join('categories as c', 'c.id = t.category_id')
            ->where('t.user_id', $userId)
            ->where('t.deleted_at', null)
            ->where('c.user_id', $userId)
            ->where('c.deleted_at', null)
            ->where('o.is_deleted', 0);

        if ($priority !== null) {
            $builder->where('t.priority', $priority);
        }

        $status = (string) ($filters['status'] ?? self::STATUS_PENDING);
        if ($status === self::STATUS_PENDING || $status === self::STATUS_COMPLETED) {
            $builder->where('o.status', $status);
        }

        $category = (string) ($filters['category'] ?? 'all');
        if ($category !== 'all') {
            $builder->where('t.category_id', (int) $category);
        }

        [$from, $to] = $this->dateBounds($filters);
        if ($from !== null && $from !== '' && $to !== null && $to !== '') {
            $builder->where('o.occurrence_date >=', $from);
            $builder->where('o.occurrence_date <=', $to);
        }

        $titleQuery = $this->normalizeSearchQuery((string) ($titleQuery ?? ''));
        if ($titleQuery !== '') {
            $builder->like('t.title', $titleQuery, 'both', null, true);
        }

        $builder->orderBy('o.occurrence_date IS NULL', 'ASC', false);
        $builder->orderBy('o.occurrence_date', 'ASC');
        $builder->orderBy('o.occurrence_time IS NULL', 'ASC', false);
        $builder->orderBy('o.occurrence_time', 'ASC');
        $builder->orderBy('o.id', 'ASC');

        $rows = $builder->get()->getResultArray();
        $presented = [];
        foreach ($rows as $row) {
            $presented[] = $this->present($row);
        }

        return $presented;
    }

    /**
     * @param array<string, mixed> $filters
     *
     * @return array<int, list<array<string, mixed>>>
     */
    public function groupedByPriority(int $userId, array $filters): array
    {
        $grouped = [1 => [], 2 => [], 3 => [], 4 => []];
        foreach ($this->occurrences($userId, $filters) as $row) {
            $priority = (int) ($row['priority'] ?? 0);
            if (isset($grouped[$priority])) {
                $grouped[$priority][] = $row;
            }
        }

        return $grouped;
    }

    /**
     * @param array<string, mixed> $row
     *
     * @return array<string, mixed>
     */
    public function present(array $row): array
    {
        $row['meta']           = $this->formatMeta($row);
        $row['edit_path']      = 'tasks/' . (int) $row['task_id'] . '/edit';
        $row['is_recurring']   = (int) ($row['is_recurring'] ?? 0) === 1 ? 1 : 0;
        $row['priority_label'] = $this->priorityLabel((int) ($row['priority'] ?? 0));

        return $row;
    }

    /**
     * Title-only search using the same occurrence + filter query as the Dashboard.
     * Empty/whitespace queries return no rows (they do not list everything).
     *
     * @param array<string, mixed> $filters
     *
     * @return list<array<string, mixed>>
     */
    public function searchOccurrences(int $userId, array $filters, string $query): array
    {
        $query = $this->normalizeSearchQuery($query);
        if ($query === '') {
            return [];
        }

        $rows = $this->occurrences($userId, $filters, null, $query);
        foreach ($rows as $index => $row) {
            $rows[$index]['title_html'] = $this->highlightTitle((string) ($row['title'] ?? ''), $query);
        }

        return $rows;
    }

    public function normalizeSearchQuery(mixed $raw): string
    {
        $query = trim((string) $raw);
        if ($query === '') {
            return '';
        }
        if (mb_strlen($query) > self::SEARCH_QUERY_MAX) {
            return mb_substr($query, 0, self::SEARCH_QUERY_MAX);
        }

        return $query;
    }

    public function priorityLabel(int $priority): string
    {
        return (string) ($this->config->priorities[$priority] ?? '');
    }

    /**
     * Wrap case-insensitive title matches in mark tags. Title and query are
     * escaped; the query is never concatenated as raw HTML.
     */
    public function highlightTitle(string $title, string $query): string
    {
        $query = $this->normalizeSearchQuery($query);
        if ($query === '' || $title === '') {
            return esc($title);
        }

        $length = mb_strlen($query);
        $html   = '';
        $rest   = $title;
        $guard  = 0;
        while ($rest !== '' && $guard++ < 50) {
            $pos = mb_stripos($rest, $query);
            if ($pos === false) {
                $html .= esc($rest);
                break;
            }
            if ($pos > 0) {
                $html .= esc(mb_substr($rest, 0, $pos));
            }
            $html .= '<mark class="search-hit">' . esc(mb_substr($rest, $pos, $length)) . '</mark>';
            $rest = mb_substr($rest, $pos + $length);
        }

        return $html;
    }

    public function searchEmptyMessage(string $query): string
    {
        return 'No tasks found for "' . $query . '".';
    }

    /**
     * Category • Date • Time using only pieces that exist. Never emits empty bullets.
     *
     * @param array<string, mixed> $row
     */
    public function formatMeta(array $row): string
    {
        $parts = [];
        $name  = trim((string) ($row['category_name'] ?? ''));
        if ($name !== '') {
            $parts[] = $name;
        }

        $date = $this->formatDate($row['occurrence_date'] ?? null);
        if ($date !== null) {
            $parts[] = $date;
        }

        $time = $this->formatTime($row['occurrence_time'] ?? null);
        if ($time !== null && $date !== null) {
            $parts[] = $time;
        }

        return implode(' • ', $parts);
    }

    public function formatDate(mixed $value): ?string
    {
        $raw = is_string($value) ? trim($value) : '';
        if ($raw === '') {
            return null;
        }

        $date = DateTimeImmutable::createFromFormat('!Y-m-d', substr($raw, 0, 10));
        if ($date === false) {
            return null;
        }

        return $date->format('j M Y');
    }

    public function formatTime(mixed $value): ?string
    {
        $raw = is_string($value) ? trim($value) : '';
        if ($raw === '') {
            return null;
        }

        if (preg_match('/^(\d{2}):(\d{2})/', $raw, $match) !== 1) {
            return null;
        }

        $date = DateTimeImmutable::createFromFormat('!H:i', $match[1] . ':' . $match[2]);
        if ($date === false) {
            return null;
        }

        return $date->format('g:i A');
    }

    /**
     * @param array<string, mixed> $filters
     */
    public function emptyMessage(array $filters): string
    {
        $date   = (string) ($filters['date'] ?? self::DATE_TODAY);
        $status = (string) ($filters['status'] ?? self::STATUS_PENDING);
        $category = (string) ($filters['category'] ?? 'all');

        if ($date === self::DATE_TODAY && $status === self::STATUS_PENDING && $category === 'all') {
            return 'No tasks due today.';
        }
        if ($status === self::STATUS_PENDING) {
            return 'No pending tasks match these filters.';
        }
        if ($status === self::STATUS_COMPLETED) {
            return 'No completed tasks match these filters.';
        }

        return 'No tasks match these filters.';
    }

    /**
     * @param array<string, mixed> $filters
     */
    public function dateLabel(array $filters): string
    {
        return match ((string) ($filters['date'] ?? '')) {
            self::DATE_ALL      => 'All Dates',
            self::DATE_TODAY    => 'Today',
            self::DATE_TOMORROW => 'Tomorrow',
            self::DATE_WEEK     => 'This Week',
            self::DATE_NEXT7    => 'Next 7 Days',
            self::DATE_MONTH    => 'This Month',
            self::DATE_CUSTOM   => $this->customLabel($filters),
            default             => 'Today',
        };
    }

    /**
     * @param array<string, mixed> $filters
     */
    public function statusLabel(array $filters): string
    {
        return match ((string) ($filters['status'] ?? '')) {
            self::STATUS_ALL       => 'All',
            self::STATUS_COMPLETED => 'Completed',
            default                => 'Pending',
        };
    }

    /**
     * @param array<string, mixed> $filters
     * @param list<array<string, mixed>> $categories
     */
    public function categoryLabel(array $filters, array $categories): string
    {
        $id = (string) ($filters['category'] ?? 'all');
        if ($id === 'all') {
            return 'All Categories';
        }

        foreach ($categories as $category) {
            if ((string) ($category['id'] ?? '') === $id) {
                return (string) $category['name'];
            }
        }

        return 'All Categories';
    }

    /**
     * @param array<string, mixed> $filters
     * @param array<string, string> $override
     */
    public function href(string $path, array $filters, array $override = []): string
    {
        $merged = array_merge($filters, $override);
        if (($merged['date'] ?? '') !== self::DATE_CUSTOM) {
            $merged['from'] = '';
            $merged['to']   = '';
        }

        return rtrim(site_url(ltrim($path, '/')), '/') . $this->querySuffix($merged);
    }

    /**
     * @param array<string, mixed> $filters
     */
    public function querySuffix(array $filters): string
    {
        $query = [
            'date'     => (string) ($filters['date'] ?? self::DATE_TODAY),
            'status'   => (string) ($filters['status'] ?? self::STATUS_PENDING),
            'category' => (string) ($filters['category'] ?? 'all'),
        ];
        $search = $this->normalizeSearchQuery($filters['q'] ?? '');
        if ($search !== '') {
            $query['q'] = $search;
        }
        if ($query['date'] === self::DATE_CUSTOM) {
            $query['from'] = (string) ($filters['from'] ?? '');
            $query['to']   = (string) ($filters['to'] ?? '');
        }

        return '?' . http_build_query($query);
    }

    /**
     * @param array<string, mixed> $filters
     */
    public function rememberReturn(string $path, array $filters): void
    {
        session()->set(self::RETURN_KEY, ltrim($path, '/') . $this->querySuffix($filters));
        session()->set(self::SESSION_KEY, $filters);
    }

    public function safeReturn(?string $raw): string
    {
        $raw = trim((string) $raw);
        if ($raw === '' || preg_match('#^[a-z][a-z0-9+.-]*://#i', $raw) === 1 || str_starts_with($raw, '//')) {
            return '/dashboard';
        }

        $raw = ltrim($raw, '/');
        $pathOnly = explode('?', $raw, 2)[0];
        if (str_contains($pathOnly, '..')) {
            return '/dashboard';
        }
        if ($raw === 'dashboard' || str_starts_with($raw, 'dashboard?')) {
            return '/' . $this->allowlistedReturn('dashboard', $raw);
        }
        if (preg_match('#^tasks/priority/[1-4](\?.*)?$#', $raw) === 1) {
            $path = (string) preg_replace('/\?.*$/', '', $raw);

            return '/' . $this->allowlistedReturn($path, $raw);
        }
        if (preg_match('#^tasks/search(\?.*)?$#', $raw) === 1) {
            return '/' . $this->allowlistedReturn('tasks/search', $raw);
        }
        if ($pathOnly === 'notifications') {
            return '/notifications';
        }

        return '/dashboard';
    }

    /**
     * Keep only date/status/category/from/to/q on return URLs.
     */
    private function allowlistedReturn(string $path, string $raw): string
    {
        $queryString = '';
        if (str_contains($raw, '?')) {
            $queryString = substr($raw, (int) strpos($raw, '?') + 1);
        }
        if ($queryString === '') {
            return $path;
        }

        $parsed = [];
        parse_str($queryString, $parsed);
        if (! is_array($parsed)) {
            return $path;
        }

        $allowed = [];
        $date = $this->sanitizeDateMode($parsed['date'] ?? null);
        if ($date !== null) {
            $allowed['date'] = $date;
        }
        $status = $this->sanitizeStatusMode($parsed['status'] ?? null);
        if ($status !== null) {
            $allowed['status'] = $status;
        }
        if (isset($parsed['category'])) {
            $category = (string) $parsed['category'];
            if ($category === 'all' || ctype_digit($category)) {
                $allowed['category'] = $category;
            }
        }
        if (($allowed['date'] ?? '') === self::DATE_CUSTOM) {
            $from = $this->tasks->normalizeDate(trim((string) ($parsed['from'] ?? '')));
            $to   = $this->tasks->normalizeDate(trim((string) ($parsed['to'] ?? '')));
            if ($from !== null && $to !== null && $to >= $from) {
                $allowed['from'] = $from;
                $allowed['to']   = $to;
            }
        }
        $search = $this->normalizeSearchQuery($parsed['q'] ?? '');
        if ($search !== '') {
            $allowed['q'] = $search;
        }

        return $allowed === [] ? $path : $path . '?' . http_build_query($allowed);
    }

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

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

    public function sanitizeDateMode(mixed $value): ?string
    {
        $mode = is_string($value) ? strtolower(trim($value)) : '';

        return in_array($mode, self::DATE_MODES, true) ? $mode : null;
    }

    public function sanitizeStatusMode(mixed $value): ?string
    {
        $mode = is_string($value) ? strtolower(trim($value)) : '';

        return in_array($mode, self::STATUS_MODES, true) ? $mode : null;
    }

    public function sanitizeCategory(int $userId, mixed $value): string
    {
        if ($value === null || $value === '' || $value === 'all') {
            return 'all';
        }
        if (! ctype_digit((string) $value)) {
            return 'all';
        }
        $id = (int) $value;
        if ($id < 1 || $this->tasks->ownedCategory($userId, $id) === null) {
            return 'all';
        }

        return (string) $id;
    }

    /**
     * @param array<string, mixed> $filters
     *
     * @return array{0: string|null, 1: string|null}
     */
    public function dateBounds(array $filters): array
    {
        $today = $this->today();
        $mode  = (string) ($filters['date'] ?? self::DATE_TODAY);

        return match ($mode) {
            self::DATE_ALL      => [null, null],
            self::DATE_TODAY    => [$today->format('Y-m-d'), $today->format('Y-m-d')],
            self::DATE_TOMORROW => [
                $today->modify('+1 day')->format('Y-m-d'),
                $today->modify('+1 day')->format('Y-m-d'),
            ],
            self::DATE_WEEK     => $this->thisWeekBounds($today),
            self::DATE_NEXT7    => [
                $today->format('Y-m-d'),
                $today->modify('+6 days')->format('Y-m-d'),
            ],
            self::DATE_MONTH    => [
                $today->format('Y-m-01'),
                $today->format('Y-m-t'),
            ],
            self::DATE_CUSTOM   => [
                (string) ($filters['from'] ?? ''),
                (string) ($filters['to'] ?? ''),
            ],
            default             => [$today->format('Y-m-d'), $today->format('Y-m-d')],
        };
    }

    /**
     * @return array{0: string, 1: string}
     */
    private function thisWeekBounds(DateTimeImmutable $today): array
    {
        $dow    = (int) $today->format('N');
        $monday = $today->modify('-' . ($dow - 1) . ' days');
        $sunday = $monday->modify('+6 days');

        return [$monday->format('Y-m-d'), $sunday->format('Y-m-d')];
    }

    /**
     * @param array<string, mixed> $filters
     */
    private function customLabel(array $filters): string
    {
        $from = $this->formatDate($filters['from'] ?? null);
        $to   = $this->formatDate($filters['to'] ?? null);
        if ($from === null || $to === null) {
            return 'Custom Date(s)';
        }
        if ($from === $to) {
            return $from;
        }

        return $from . ' – ' . $to;
    }

    /**
     * @param array<string, mixed>|null $stored
     *
     * @return array{date: string, status: string, category: string, from: string, to: string}
     */
    private function sanitizeStored(?array $stored): array
    {
        $defaults = $this->defaults();
        if ($stored === null) {
            return $defaults;
        }

        $date = $this->sanitizeDateMode($stored['date'] ?? null) ?? $defaults['date'];
        $filters = [
            'date'     => $date,
            'status'   => $this->sanitizeStatusMode($stored['status'] ?? null) ?? $defaults['status'],
            'category' => (string) ($stored['category'] ?? 'all') === 'all' || ctype_digit((string) ($stored['category'] ?? ''))
                ? (string) ($stored['category'] ?? 'all')
                : 'all',
            'from'     => (string) ($stored['from'] ?? ''),
            'to'       => (string) ($stored['to'] ?? ''),
        ];
        if ($filters['date'] !== self::DATE_CUSTOM) {
            $filters['from'] = '';
            $filters['to']   = '';
        }

        return $filters;
    }

    /**
     * @param array<string, mixed> $query
     */
    private function queryHasFilterKeys(array $query): bool
    {
        foreach (['date', 'status', 'category', 'from', 'to'] as $key) {
            if (array_key_exists($key, $query) && $query[$key] !== null && $query[$key] !== '') {
                return true;
            }
        }

        return false;
    }
}
