<?php

declare(strict_types=1);

namespace CalendarPlugin\Domain;

use CalendarPlugin\Contracts\DatabaseAdapterInterface;
use DateTimeImmutable;
use DateTimeZone;

final class EventService
{
    private readonly string $eventsTable;
    private readonly string $exceptionsTable;
    private readonly string $tombstonesTable;

    public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
    {
        $prefix = $db->getPrefix();
        $stem = trim($tableStem, '_');
        $this->eventsTable = $prefix . $stem . '_events';
        $this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
        $this->tombstonesTable = $prefix . $stem . '_caldav_tombstones';
    }

    public function listEvents(): array
    {
        $rows = $this->db->getResults("SELECT * FROM {$this->eventsTable} ORDER BY id ASC");
        return array_map([$this, 'normalizeRow'], $rows);
    }

    public function getEvent(int $id): ?array
    {
        $sql = $this->db->prepare("SELECT * FROM {$this->eventsTable} WHERE id = %d", $id);
        $row = $this->db->getRow($sql);
        return $row ? $this->normalizeRow($row) : null;
    }

    public function createEvent(array $payload): array
    {
        $now = gmdate('c');
        $uid = (string) ($payload['uid'] ?? bin2hex(random_bytes(10)) . '@calendar-plugin');
        $resource = $this->resourceFromUid($uid);
        $title = trim((string) ($payload['title'] ?? 'Untitled'));
        $startRaw = (string) ($payload['start_datetime'] ?? '');
        $endRaw = (string) ($payload['end_datetime'] ?? '');
        $start = $this->toLondonDateTimeString($startRaw);
        $end = $this->toLondonDateTimeString($endRaw);
        if ($start === '' || $end === '') {
            throw new \InvalidArgumentException('start_datetime and end_datetime are required');
        }
        if (new DateTimeImmutable($end) < new DateTimeImmutable($start)) {
            throw new \InvalidArgumentException('end_datetime must be at or after start_datetime');
        }
        $repeatType = (string) ($payload['repeat_type'] ?? 'none');
        $repeatInterval = max(1, (int) ($payload['repeat_interval'] ?? 1));
        $repeatNthMode = (string) ($payload['repeat_nth_mode'] ?? '');
        $repeatNthDay = array_key_exists('repeat_nth_day', $payload) && $payload['repeat_nth_day'] !== null && $payload['repeat_nth_day'] !== ''
            ? (int) $payload['repeat_nth_day']
            : null;
        $repeatNthPos = array_key_exists('repeat_nth_pos', $payload) && $payload['repeat_nth_pos'] !== null && $payload['repeat_nth_pos'] !== ''
            ? (int) $payload['repeat_nth_pos']
            : null;
        $repeatNthWeekday = array_key_exists('repeat_nth_weekday', $payload) && $payload['repeat_nth_weekday'] !== null && $payload['repeat_nth_weekday'] !== ''
            ? (int) $payload['repeat_nth_weekday']
            : null;
        [$start, $end] = $this->normalizeMonthlyAnchor(
            $start,
            $end,
            $repeatType,
            $repeatInterval,
            $repeatNthMode,
            $repeatNthDay,
            $repeatNthPos,
            $repeatNthWeekday
        );

        $data = [
            'uid' => $uid,
            'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
            'title' => $title,
            'description' => (string) ($payload['description'] ?? ''),
            'location' => (string) ($payload['location'] ?? ''),
            'category' => (string) ($payload['category'] ?? ''),
            'all_day_event' => !empty($payload['all_day_event']) ? 1 : 0,
            'start_datetime' => $start,
            'end_datetime' => $end,
            'repeat_type' => $repeatType,
            'repeat_interval' => $repeatInterval,
            'repeat_nth_mode' => $repeatNthMode,
            'repeat_nth_day' => $repeatNthDay,
            'repeat_nth_pos' => $repeatNthPos,
            'repeat_nth_weekday' => $repeatNthWeekday,
            'repeat_range_mode' => $this->canonicalRangeMode((string) ($payload['repeat_range_mode'] ?? 'none')),
            'repeat_count' => isset($payload['repeat_count']) ? (int) $payload['repeat_count'] : null,
            'repeat_until' => !empty($payload['repeat_until']) ? (string) $payload['repeat_until'] : null,
            'timezone' => (string) ($payload['timezone'] ?? 'Europe/London'),
            'caldav_resource' => !empty($payload['caldav_resource']) ? (string) $payload['caldav_resource'] : $resource,
            'etag' => (string) ($payload['etag'] ?? $this->makeEtag($uid, 1, $now)),
            'sync_version' => (int) ($payload['sync_version'] ?? 1),
            'last_modified_by_user_id' => isset($payload['last_modified_by_user_id']) ? (int) $payload['last_modified_by_user_id'] : null,
            'created_at' => $now,
            'updated_at' => $now,
        ];

        $inserted = $this->db->insert($this->eventsTable, $data);
        if ($inserted === false) {
            throw new \RuntimeException('failed to create event');
        }
        $created = (array) $this->getEvent($this->db->insertId());
        $resource = trim((string) ($created['caldav_resource'] ?? ''));
        if ($resource !== '') {
            $this->clearCalDavTombstone($resource);
        }
        return $created;
    }

    public function updateEvent(int $id, array $payload): ?array
    {
        $existing = $this->getEvent($id);
        if (!$existing) {
            return null;
        }
        $now = gmdate('c');
        $currentResource = trim((string) ($existing['caldav_resource'] ?? ''));
        $fallbackResource = $this->resourceFromUid((string) ($existing['uid'] ?? ''));
        $start = array_key_exists('start_datetime', $payload)
            ? $this->toLondonDateTimeString((string) $payload['start_datetime'])
            : (string) $existing['start_datetime'];
        $end = array_key_exists('end_datetime', $payload)
            ? $this->toLondonDateTimeString((string) $payload['end_datetime'])
            : (string) $existing['end_datetime'];
        if ($start !== '' && $end !== '' && new DateTimeImmutable($end) < new DateTimeImmutable($start)) {
            throw new \InvalidArgumentException('end_datetime must be at or after start_datetime');
        }
        $repeatType = (string) ($payload['repeat_type'] ?? $existing['repeat_type']);
        $repeatInterval = max(1, (int) ($payload['repeat_interval'] ?? $existing['repeat_interval']));
        $repeatNthMode = (string) ($payload['repeat_nth_mode'] ?? ($existing['repeat_nth_mode'] ?? ''));
        $repeatNthDay = array_key_exists('repeat_nth_day', $payload)
            ? ($payload['repeat_nth_day'] === null || $payload['repeat_nth_day'] === '' ? null : (int) $payload['repeat_nth_day'])
            : ($existing['repeat_nth_day'] ?? null);
        $repeatNthPos = array_key_exists('repeat_nth_pos', $payload)
            ? ($payload['repeat_nth_pos'] === null || $payload['repeat_nth_pos'] === '' ? null : (int) $payload['repeat_nth_pos'])
            : ($existing['repeat_nth_pos'] ?? null);
        $repeatNthWeekday = array_key_exists('repeat_nth_weekday', $payload)
            ? ($payload['repeat_nth_weekday'] === null || $payload['repeat_nth_weekday'] === '' ? null : (int) $payload['repeat_nth_weekday'])
            : ($existing['repeat_nth_weekday'] ?? null);
        [$start, $end] = $this->normalizeMonthlyAnchor(
            $start,
            $end,
            $repeatType,
            $repeatInterval,
            $repeatNthMode,
            $repeatNthDay,
            $repeatNthPos,
            $repeatNthWeekday
        );
        $data = [
            'visibility' => array_key_exists('visibility', $payload)
                ? $this->canonicalVisibility((string) $payload['visibility'])
                : $this->canonicalVisibility((string) ($existing['visibility'] ?? 'public')),
            'title' => trim((string) ($payload['title'] ?? $existing['title'])),
            'description' => (string) ($payload['description'] ?? $existing['description']),
            'location' => (string) ($payload['location'] ?? $existing['location']),
            'category' => (string) ($payload['category'] ?? $existing['category']),
            'all_day_event' => array_key_exists('all_day_event', $payload)
                ? (!empty($payload['all_day_event']) ? 1 : 0)
                : ((bool) $existing['all_day_event'] ? 1 : 0),
            'start_datetime' => $start,
            'end_datetime' => $end,
            'repeat_type' => $repeatType,
            'repeat_interval' => $repeatInterval,
            'repeat_nth_mode' => $repeatNthMode,
            'repeat_nth_day' => $repeatNthDay,
            'repeat_nth_pos' => $repeatNthPos,
            'repeat_nth_weekday' => $repeatNthWeekday,
            'repeat_range_mode' => $this->canonicalRangeMode((string) ($payload['repeat_range_mode'] ?? $existing['repeat_range_mode'])),
            'repeat_count' => array_key_exists('repeat_count', $payload) ? (is_null($payload['repeat_count']) ? null : (int) $payload['repeat_count']) : $existing['repeat_count'],
            'repeat_until' => array_key_exists('repeat_until', $payload) ? (empty($payload['repeat_until']) ? null : (string) $payload['repeat_until']) : $existing['repeat_until'],
            'timezone' => (string) ($payload['timezone'] ?? $existing['timezone']),
            'caldav_resource' => !empty($payload['caldav_resource'])
                ? (string) $payload['caldav_resource']
                : ($currentResource !== '' ? $currentResource : $fallbackResource),
            'etag' => (string) ($payload['etag'] ?? $existing['etag'] ?? $this->makeEtag((string) $existing['uid'], (int) ($existing['sync_version'] ?? 1), $now)),
            'sync_version' => (int) ($payload['sync_version'] ?? (($existing['sync_version'] ?? 1) + 1)),
            'last_modified_by_user_id' => array_key_exists('last_modified_by_user_id', $payload)
                ? (is_null($payload['last_modified_by_user_id']) ? null : (int) $payload['last_modified_by_user_id'])
                : ($existing['last_modified_by_user_id'] ?? null),
            'updated_at' => $now,
        ];

        $this->db->update($this->eventsTable, $data, ['id' => $id]);
        $updated = $this->getEvent($id);
        if ($updated) {
            $resource = trim((string) ($updated['caldav_resource'] ?? ''));
            if ($resource !== '') {
                $this->clearCalDavTombstone($resource);
            }
        }
        return $updated;
    }

    public function deleteEvent(int $id): bool
    {
        $event = $this->getEvent($id);
        $this->db->delete($this->exceptionsTable, ['event_id' => $id]);
        $deleted = $this->db->delete($this->eventsTable, ['id' => $id]);
        if ($deleted !== false && $event) {
            $resource = trim((string) ($event['caldav_resource'] ?? ''));
            if ($resource !== '') {
                $this->recordCalDavTombstone($resource);
            }
        }
        return $deleted !== false;
    }

    public function deleteOccurrence(int $eventId, string $occurrenceKey): bool
    {
        $event = $this->getEvent($eventId);
        if (!$event) {
            return false;
        }
        $canonical = $this->canonicalOccurrenceKey($occurrenceKey);
        if ($canonical === null) {
            return false;
        }
        if (in_array($canonical, $this->deletedKeysForEvent($eventId), true)) {
            return true;
        }
        $now = gmdate('c');
        $inserted = $this->db->insert(
            $this->exceptionsTable,
            [
                'event_id' => $eventId,
                'occurrence_key' => $canonical,
                'exception_type' => 'deleted_occurrence',
                'created_at' => $now,
                'updated_at' => $now,
            ]
        );
        return $inserted !== false;
    }

    public function listEventOccurrences(int $eventId, string $fromDate, int $months = 3): ?array
    {
        $event = $this->getEvent($eventId);
        if (!$event) {
            return null;
        }

        $tz = new DateTimeZone('Europe/London');
        $start = $this->safeDate($fromDate, $tz)->setTime(0, 0, 0);
        $months = max(1, min($months, 24));
        $end = $start->modify('+' . $months . ' month');
        $deleted = $this->deletedKeysForEvent($eventId);

        $items = RecurrenceExpander::expand($event, $start, $end, $deleted);
        usort(
            $items,
            static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
        );
        return $items;
    }

    public function previewOccurrences(array $payload, string $fromDate, int $months = 3): array
    {
        $startRaw = (string) ($payload['start_datetime'] ?? '');
        $endRaw = (string) ($payload['end_datetime'] ?? '');
        $start = $this->toLondonDateTimeString($startRaw);
        $end = $this->toLondonDateTimeString($endRaw);
        if ($start === '' || $end === '') {
            throw new \InvalidArgumentException('start_datetime and end_datetime are required');
        }
        if (new DateTimeImmutable($end) < new DateTimeImmutable($start)) {
            throw new \InvalidArgumentException('end_datetime must be at or after start_datetime');
        }

        $repeatType = (string) ($payload['repeat_type'] ?? 'none');
        if ($repeatType === 'none') {
            return [];
        }
        $repeatInterval = max(1, (int) ($payload['repeat_interval'] ?? 1));
        $repeatNthMode = (string) ($payload['repeat_nth_mode'] ?? '');
        $repeatNthDay = array_key_exists('repeat_nth_day', $payload) && $payload['repeat_nth_day'] !== null && $payload['repeat_nth_day'] !== ''
            ? (int) $payload['repeat_nth_day']
            : null;
        $repeatNthPos = array_key_exists('repeat_nth_pos', $payload) && $payload['repeat_nth_pos'] !== null && $payload['repeat_nth_pos'] !== ''
            ? (int) $payload['repeat_nth_pos']
            : null;
        $repeatNthWeekday = array_key_exists('repeat_nth_weekday', $payload) && $payload['repeat_nth_weekday'] !== null && $payload['repeat_nth_weekday'] !== ''
            ? (int) $payload['repeat_nth_weekday']
            : null;
        [$start, $end] = $this->normalizeMonthlyAnchor(
            $start,
            $end,
            $repeatType,
            $repeatInterval,
            $repeatNthMode,
            $repeatNthDay,
            $repeatNthPos,
            $repeatNthWeekday
        );
        $event = [
            'id' => 0,
            'uid' => 'preview@calendar-plugin',
            'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
            'title' => (string) ($payload['title'] ?? ''),
            'description' => (string) ($payload['description'] ?? ''),
            'location' => (string) ($payload['location'] ?? ''),
            'category' => (string) ($payload['category'] ?? ''),
            'all_day_event' => !empty($payload['all_day_event']),
            'start_datetime' => $start,
            'end_datetime' => $end,
            'repeat_type' => $repeatType,
            'repeat_interval' => $repeatInterval,
            'repeat_nth_mode' => $repeatNthMode,
            'repeat_nth_day' => $repeatNthDay,
            'repeat_nth_pos' => $repeatNthPos,
            'repeat_nth_weekday' => $repeatNthWeekday,
            'repeat_range_mode' => $this->canonicalRangeMode((string) ($payload['repeat_range_mode'] ?? 'none')),
            'repeat_count' => isset($payload['repeat_count']) ? (int) $payload['repeat_count'] : null,
            'repeat_until' => !empty($payload['repeat_until']) ? (string) $payload['repeat_until'] : null,
            'timezone' => 'Europe/London',
        ];

        $tz = new DateTimeZone('Europe/London');
        $startWindow = $this->safeDate($fromDate, $tz)->setTime(0, 0, 0);
        $months = max(1, min($months, 24));
        $endWindow = $startWindow->modify('+' . $months . ' month');
        $deleted = [];
        foreach ((array) ($payload['deleted_occurrence_keys'] ?? []) as $key) {
            $canonical = $this->canonicalOccurrenceKey((string) $key);
            if ($canonical !== null) {
                $deleted[] = $canonical;
            }
        }
        $items = RecurrenceExpander::expand($event, $startWindow, $endWindow, $deleted);
        usort(
            $items,
            static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
        );
        return $items;
    }

    public function getEventByResource(string $resource): ?array
    {
        $sql = $this->db->prepare("SELECT * FROM {$this->eventsTable} WHERE caldav_resource = %s", $resource);
        $row = $this->db->getRow($sql);
        return $row ? $this->normalizeRow($row) : null;
    }

    public function getDeletedOccurrenceKeys(int $eventId): array
    {
        return $this->deletedKeysForEvent($eventId);
    }

    public function syncDeletedOccurrenceKeys(int $eventId, array $keys, bool $replace = true): void
    {
        if ($replace) {
            $this->db->delete($this->exceptionsTable, ['event_id' => $eventId, 'exception_type' => 'deleted_occurrence']);
        }
        $now = gmdate('c');
        foreach ($keys as $key) {
            $canonical = $this->canonicalOccurrenceKey((string) $key);
            if ($canonical === null) {
                continue;
            }
            $this->db->insert(
                $this->exceptionsTable,
                [
                    'event_id' => $eventId,
                    'occurrence_key' => $canonical,
                    'exception_type' => 'deleted_occurrence',
                    'created_at' => $now,
                    'updated_at' => $now,
                ]
            );
        }
    }

    public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false, bool $redactPrivate = true): array
    {
        $tz = new DateTimeZone('Europe/London');
        $anchor = $this->safeDate($dateAnchor, $tz);
        if (strtolower($view) === 'list') {
            $windowStart = $anchor->setTime(0, 0, 0);
            if ($futureOnly) {
                $today = new DateTimeImmutable('today', $tz);
                if ($today > $windowStart) {
                    $windowStart = $today;
                }
            }
            $windowEnd = $windowStart->modify('+18 months');
        } else {
            [$windowStart, $windowEnd] = $this->windowForView($view, $anchor);
        }
        $events = $this->listEvents();

        $out = [];
        foreach ($events as $event) {
            $deleted = $this->deletedKeysForEvent((int) $event['id']);
            $items = RecurrenceExpander::expand($event, $windowStart, $windowEnd, $deleted);
            array_push($out, ...$items);
        }

        usort(
            $out,
            static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
        );

        if ($redactPrivate) {
            return array_map([$this, 'redactOccurrenceForPublic'], $out);
        }

        return $out;
    }

    public function listSidebarUpcoming(int $days = 14, bool $redactPrivate = true): array
    {
        $tz = new DateTimeZone('Europe/London');
        $start = new DateTimeImmutable('today', $tz);
        $end = $start->modify('+' . max(1, $days) . ' days');

        $events = $this->listEvents();
        $out = [];
        foreach ($events as $event) {
            $deleted = $this->deletedKeysForEvent((int) $event['id']);
            $items = RecurrenceExpander::expand($event, $start, $end, $deleted);
            array_push($out, ...$items);
        }

        usort(
            $out,
            static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
        );

        if ($redactPrivate) {
            return array_map([$this, 'redactOccurrenceForPublic'], $out);
        }

        return $out;
    }

    public function deleteAllEventsData(): int
    {
        $events = $this->listEvents();
        $count = count($events);
        foreach ($events as $event) {
            $resource = trim((string) ($event['caldav_resource'] ?? ''));
            if ($resource !== '') {
                $this->recordCalDavTombstone($resource);
            }
        }
        $this->db->query("DELETE FROM {$this->exceptionsTable}");
        $this->db->query("DELETE FROM {$this->eventsTable}");
        return $count;
    }

    public function listCalDavTombstones(int $limit = 500): array
    {
        $limit = max(1, min($limit, 5000));
        $rows = $this->db->getResults(
            "SELECT resource, deleted_at FROM {$this->tombstonesTable} ORDER BY deleted_at DESC LIMIT {$limit}"
        );
        return array_map(static function (object $row): array {
            return [
                'resource' => (string) ($row->resource ?? ''),
                'deleted_at' => (string) ($row->deleted_at ?? ''),
            ];
        }, $rows);
    }

    public function seedDefaultEvents(): int
    {
        $seed = [
            [
                'uid' => 'seed-ce-001@calendar-plugin',
                'title' => 'Board Meeting',
                'description' => 'Quarterly board review.',
                'location' => 'Room A',
                'category' => 'Governance',
                'start_datetime' => '2026-04-01T10:00:00+01:00',
                'end_datetime' => '2026-04-01T11:30:00+01:00',
                'repeat_type' => 'none',
            ],
            [
                'uid' => 'seed-ce-002@calendar-plugin',
                'title' => 'Office Closed',
                'description' => 'Public holiday closure.',
                'location' => 'HQ',
                'category' => 'Operations',
                'all_day_event' => true,
                'start_datetime' => '2026-05-04T00:00:00+01:00',
                'end_datetime' => '2026-05-05T00:00:00+01:00',
                'repeat_type' => 'none',
            ],
            [
                'uid' => 'seed-ce-003@calendar-plugin',
                'title' => 'Daily Standup',
                'description' => '15 minute sync.',
                'location' => 'Online',
                'category' => 'Team',
                'start_datetime' => '2026-04-06T09:00:00+01:00',
                'end_datetime' => '2026-04-06T09:15:00+01:00',
                'repeat_type' => 'daily',
                'repeat_interval' => 1,
                'repeat_range_mode' => 'until',
                'repeat_until' => '2026-04-15',
            ],
            [
                'uid' => 'seed-ce-004@calendar-plugin',
                'title' => 'Community Lunch',
                'description' => 'Weekly community lunch.',
                'location' => 'Cafeteria',
                'category' => 'Community',
                'start_datetime' => '2026-04-08T12:30:00+01:00',
                'end_datetime' => '2026-04-08T13:30:00+01:00',
                'repeat_type' => 'weekly',
                'repeat_interval' => 1,
                'repeat_range_mode' => 'until',
                'repeat_until' => '2026-05-06',
            ],
            [
                'uid' => 'seed-ce-005@calendar-plugin',
                'title' => 'Finance Close',
                'description' => 'Month-end close process.',
                'location' => 'Finance Office',
                'category' => 'Finance',
                'start_datetime' => '2026-03-31T17:00:00+01:00',
                'end_datetime' => '2026-03-31T18:00:00+01:00',
                'repeat_type' => 'monthly',
                'repeat_interval' => 1,
                'repeat_nth_mode' => 'day_of_month',
                'repeat_nth_day' => 30,
                'repeat_range_mode' => 'until',
                'repeat_until' => '2026-06-30',
            ],
        ];

        foreach ($seed as $event) {
            $this->createEvent($event);
        }
        return count($seed);
    }

    private function normalizeRow(object $row): array
    {
        return [
            'id' => (int) $row->id,
            'uid' => (string) $row->uid,
            'visibility' => property_exists($row, 'visibility')
                ? $this->canonicalVisibility((string) ($row->visibility ?? 'public'))
                : 'public',
            'title' => (string) $row->title,
            'description' => (string) $row->description,
            'location' => (string) $row->location,
            'category' => (string) $row->category,
            'all_day_event' => (bool) $row->all_day_event,
            'start_datetime' => (string) $row->start_datetime,
            'end_datetime' => (string) $row->end_datetime,
            'repeat_type' => (string) $row->repeat_type,
            'repeat_interval' => (int) $row->repeat_interval,
            'repeat_nth_mode' => property_exists($row, 'repeat_nth_mode') ? (string) ($row->repeat_nth_mode ?? '') : '',
            'repeat_nth_day' => property_exists($row, 'repeat_nth_day') && $row->repeat_nth_day !== null ? (int) $row->repeat_nth_day : null,
            'repeat_nth_pos' => property_exists($row, 'repeat_nth_pos') && $row->repeat_nth_pos !== null ? (int) $row->repeat_nth_pos : null,
            'repeat_nth_weekday' => property_exists($row, 'repeat_nth_weekday') && $row->repeat_nth_weekday !== null ? (int) $row->repeat_nth_weekday : null,
            'repeat_range_mode' => (string) $row->repeat_range_mode,
            'repeat_count' => is_null($row->repeat_count) ? null : (int) $row->repeat_count,
            'repeat_until' => $row->repeat_until === null ? null : (string) $row->repeat_until,
            'timezone' => (string) $row->timezone,
            'caldav_resource' => property_exists($row, 'caldav_resource') ? (string) ($row->caldav_resource ?? '') : '',
            'etag' => property_exists($row, 'etag') ? (string) ($row->etag ?? '') : '',
            'sync_version' => property_exists($row, 'sync_version') ? (int) ($row->sync_version ?? 1) : 1,
            'last_modified_by_user_id' => property_exists($row, 'last_modified_by_user_id') && $row->last_modified_by_user_id !== null
                ? (int) $row->last_modified_by_user_id
                : null,
            'created_at' => (string) $row->created_at,
            'updated_at' => (string) $row->updated_at,
        ];
    }

    private function deletedKeysForEvent(int $eventId): array
    {
        $sql = $this->db->prepare(
            "SELECT occurrence_key FROM {$this->exceptionsTable} WHERE event_id = %d AND exception_type = 'deleted_occurrence'",
            $eventId
        );
        $rows = $this->db->getResults($sql);
        return array_map(static fn(object $r): string => (string) $r->occurrence_key, $rows);
    }

    private function safeDate(string $dateAnchor, DateTimeZone $tz): DateTimeImmutable
    {
        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateAnchor)) {
            return new DateTimeImmutable($dateAnchor . 'T00:00:00', $tz);
        }
        return new DateTimeImmutable('today', $tz);
    }

    private function windowForView(string $view, DateTimeImmutable $anchor): array
    {
        $view = strtolower($view);
        if ($view === 'day') {
            $start = $anchor->setTime(0, 0, 0);
            return [$start, $start->modify('+1 day')];
        }
        if ($view === 'week') {
            $weekday = (int) $anchor->format('w');
            $start = $anchor->modify('-' . $weekday . ' day')->setTime(0, 0, 0);
            return [$start, $start->modify('+7 days')];
        }
        if ($view === 'year') {
            $start = $anchor->setDate((int) $anchor->format('Y'), 1, 1)->setTime(0, 0, 0);
            return [$start, $start->modify('+1 year')];
        }
        $monthStart = $anchor->setDate((int) $anchor->format('Y'), (int) $anchor->format('m'), 1)->setTime(0, 0, 0);
        $startWeekday = (int) $monthStart->format('w');
        $gridStart = $monthStart->modify('-' . $startWeekday . ' day');
        $gridEnd = $gridStart->modify('+42 days');
        return [$gridStart, $gridEnd];
    }

    private function canonicalOccurrenceKey(string $value): ?string
    {
        try {
            if (!str_contains($value, 'T') && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
                $dt = new DateTimeImmutable($value . 'T00:00:00', new DateTimeZone('Europe/London'));
                return $dt->format('c');
            }
            $dt = new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
            return $dt->format('c');
        } catch (\Throwable) {
            return null;
        }
    }

    private function makeEtag(string $uid, int $syncVersion, string $stamp): string
    {
        return '"' . substr(sha1($uid . ':' . $syncVersion . ':' . $stamp), 0, 16) . '"';
    }

    private function resourceFromUid(string $uid): string
    {
        $uid = trim($uid);
        if ($uid === '') {
            $uid = bin2hex(random_bytes(10)) . '@calendar-plugin';
        }
        return $uid . '.ics';
    }

    private function toLondonDateTimeString(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }
        try {
            $dt = new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
            return $dt->setTimezone(new DateTimeZone('Europe/London'))->format('c');
        } catch (\Throwable) {
            throw new \InvalidArgumentException('invalid datetime value');
        }
    }

    private function canonicalRangeMode(string $value): string
    {
        $v = strtolower(trim($value));
        if ($v === 'no_end' || $v === '') {
            return 'none';
        }
        return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
    }

    private function canonicalVisibility(string $value): string
    {
        $visibility = strtolower(trim($value));
        return $visibility === 'private' ? 'private' : 'public';
    }

    private function redactOccurrenceForPublic(array $occurrence): array
    {
        if ($this->canonicalVisibility((string) ($occurrence['visibility'] ?? 'public')) !== 'private') {
            return $occurrence;
        }

        $occurrence['title'] = 'Private Event';
        $occurrence['description'] = '';
        $occurrence['location'] = '';
        $occurrence['category'] = '';

        return $occurrence;
    }

    private function normalizeMonthlyAnchor(
        string $startIso,
        string $endIso,
        string $repeatType,
        int $repeatInterval,
        string $repeatNthMode,
        ?int $repeatNthDay,
        ?int $repeatNthPos,
        ?int $repeatNthWeekday
    ): array {
        if ($repeatType !== 'monthly') {
            return [$startIso, $endIso];
        }
        try {
            $tz = new DateTimeZone('Europe/London');
            $start = new DateTimeImmutable($startIso, $tz);
            $end = new DateTimeImmutable($endIso, $tz);
            $duration = $start->diff($end);
            $targetDay = (int) $start->format('j');
            if ($repeatNthMode === 'day_of_month' && $repeatNthDay !== null) {
                $daysInMonth = (int) $start->format('t');
                $targetDay = max(1, min($repeatNthDay, $daysInMonth));
            } elseif ($repeatNthMode === 'weekday_of_month' && $repeatNthPos !== null && $repeatNthWeekday !== null) {
                $nthDay = $this->nthWeekdayOfMonth((int) $start->format('Y'), (int) $start->format('n'), $repeatNthWeekday, $repeatNthPos);
                if ($nthDay === null) {
                    $year = (int) $start->format('Y');
                    $month = (int) $start->format('n');
                    $step = max(1, $repeatInterval);
                    for ($i = 0; $i < 120; $i++) {
                        [$year, $month] = $this->addMonths($year, $month, $step);
                        $nthDay = $this->nthWeekdayOfMonth($year, $month, $repeatNthWeekday, $repeatNthPos);
                        if ($nthDay !== null) {
                            $start = $start->setDate($year, $month, $nthDay);
                            $targetDay = $nthDay;
                            break;
                        }
                    }
                } else {
                    $targetDay = $nthDay;
                }
            }
            $anchoredStart = $start->setDate((int) $start->format('Y'), (int) $start->format('n'), $targetDay);
            $anchoredEnd = $anchoredStart->add($duration);
            return [$anchoredStart->format('c'), $anchoredEnd->format('c')];
        } catch (\Throwable) {
            return [$startIso, $endIso];
        }
    }

    private function nthWeekdayOfMonth(int $year, int $month, int $weekday, int $pos): ?int
    {
        $weekday = max(0, min(6, $weekday));
        $tz = new DateTimeZone('Europe/London');
        if ($pos === -1) {
            $last = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
            $last = $last->modify('last day of this month');
            for ($day = (int) $last->format('j'); $day >= 1; $day--) {
                $d = $last->setDate($year, $month, $day);
                if ((int) $d->format('w') === $weekday) {
                    return $day;
                }
            }
            return null;
        }

        $first = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
        $daysInMonth = (int) $first->format('t');
        $seen = 0;
        for ($day = 1; $day <= $daysInMonth; $day++) {
            $d = $first->setDate($year, $month, $day);
            if ((int) $d->format('w') !== $weekday) {
                continue;
            }
            $seen++;
            if ($seen === $pos) {
                return $day;
            }
        }
        return null;
    }

    private function addMonths(int $year, int $month, int $delta): array
    {
        $index = ($year * 12) + ($month - 1) + $delta;
        $newYear = (int) floor($index / 12);
        $newMonth = ($index % 12) + 1;
        if ($newMonth <= 0) {
            $newMonth += 12;
            $newYear -= 1;
        }
        return [$newYear, $newMonth];
    }

    private function clearCalDavTombstone(string $resource): void
    {
        $resource = trim($resource);
        if ($resource === '') {
            return;
        }
        $this->db->delete($this->tombstonesTable, ['resource' => $resource]);
    }

    private function recordCalDavTombstone(string $resource): void
    {
        $resource = trim($resource);
        if ($resource === '') {
            return;
        }
        $now = gmdate('c');
        $this->db->delete($this->tombstonesTable, ['resource' => $resource]);
        $this->db->insert(
            $this->tombstonesTable,
            [
                'resource' => $resource,
                'deleted_at' => $now,
            ]
        );
    }
}
