Your IP : 216.73.216.224


Current Path : /var/www/html/libraries/noboss/src/Util/
Upload File :
Current File : /var/www/html/libraries/noboss/src/Util/NbIcsParserUtil.php

<?php
/**
 * @package			No Boss Extensions
 * @subpackage  	No Boss Library
 * @author			No Boss Technology <contact@nobosstechnology.com>
 * @copyright		Copyright (C) 2026 No Boss Technology. All rights reserved.
 * @license			GNU Lesser General Public License version 3 or later; see <https://www.gnu.org/licenses/lgpl-3.0.en.html>
 */

namespace Noboss\Library\Util;

use Joomla\CMS\Factory;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Parser leve e proprio de arquivos iCalendar (.ics / RFC 5545).
 *
 * Converte o conteudo bruto de um feed ICS em um array normalizado de eventos cujos campos
 * espelham o que o EventModel::prepareData() do com_nobosscalendar espera
 * (event_title, initial_date, final_date, hours, is_recurrent, recurrence_type, recurrent_days, ...).
 *
 * Cada VEVENT vira UM evento normalizado (preservando o UID), e a recorrencia (RRULE) eh mapeada
 * para o modelo de recorrencia do No Boss Calendar:
 *  - WEEKLY/DAILY com INTERVAL 1  -> recurrence_type 'week-days' (recurrent_days + recurrent_days_ignore)
 *  - demais casos (INTERVAL>1, MONTHLY, YEARLY) -> recurrence_type 'specific-dates' (lista de datas expandida)
 */
abstract class NbIcsParserUtil
{
    /**
     * Horizonte (em anos) usado para expandir recorrencias sem COUNT/UNTIL.
     */
    const DEFAULT_HORIZON_YEARS = 2;

    /**
     * Numero maximo de ocorrencias geradas por evento (trava de seguranca contra RRULE abusivo).
     */
    const MAX_OCCURRENCES = 750;

    /**
     * De-para de dias da semana ICS (BYDAY) -> indice usado pelo No Boss Calendar (Domingo = 0 ... Sabado = 6).
     */
    const WEEKDAY_MAP = [
        'SU' => 0,
        'MO' => 1,
        'TU' => 2,
        'WE' => 3,
        'TH' => 4,
        'FR' => 5,
        'SA' => 6,
    ];

    /**
     * Faz o parse de uma string ICS para um array normalizado de eventos.
     *
     * @param   string        $ics        Conteudo bruto do arquivo .ics
     * @param   string|null   $timezone   Timezone destino (default: offset do Joomla)
     * @param   array         $options    'horizon_end' (string|\DateTime) e 'max_occurrences' (int)
     *
     * @return  array   Lista de eventos normalizados
     */
    public static function parse($ics, $timezone = null, array $options = [])
    {
        if (!\is_string($ics) || $ics === '') {
            return [];
        }

        $timezone = !empty($timezone) ? $timezone : self::getJoomlaTimezone();

        try {
            $tz = new \DateTimeZone($timezone);
        } catch (\Exception $e) {
            $tz = new \DateTimeZone('UTC');
        }

        $maxOccurrences = isset($options['max_occurrences']) ? (int) $options['max_occurrences'] : self::MAX_OCCURRENCES;

        // Horizonte default para recorrencias ilimitadas
        $horizonEnd = self::resolveHorizonEnd($options['horizon_end'] ?? null, $tz);

        $lines     = self::unfoldLines($ics);
        $events    = [];
        $current   = null;
        $depthSkip = 0;

        foreach ($lines as $line) {
            $upper = \strtoupper(\trim($line));

            // Ignora blocos aninhados que nao sejam o VEVENT (ex.: VALARM dentro do evento)
            if ($depthSkip > 0) {
                if (\strpos($upper, 'BEGIN:') === 0) {
                    $depthSkip++;
                } elseif (\strpos($upper, 'END:') === 0) {
                    $depthSkip--;
                }
                continue;
            }

            if ($upper === 'BEGIN:VEVENT') {
                $current = self::newRawEvent();
                continue;
            }

            if ($upper === 'END:VEVENT') {
                if ($current !== null) {
                    $event = self::buildEvent($current, $tz, $horizonEnd, $maxOccurrences);
                    if ($event !== null) {
                        $events[] = $event;
                    }
                }
                $current = null;
                continue;
            }

            // Fora de um VEVENT nao ha nada a coletar
            if ($current === null) {
                continue;
            }

            // Inicio de um bloco aninhado (ex.: VALARM): pula tudo ate o END correspondente
            if (\strpos($upper, 'BEGIN:') === 0) {
                $depthSkip = 1;
                continue;
            }

            $parsed = self::parseContentLine($line);
            if ($parsed === null) {
                continue;
            }

            self::collectProperty($current, $parsed);
        }

        return $events;
    }

    /**
     * Extrai o nome do calendario a partir das propriedades de VCALENDAR.
     *
     * Le propriedades no nivel VCALENDAR (antes do primeiro BEGIN:VEVENT, fora de blocos aninhados).
     * Prioridade: X-WR-CALNAME -> NAME (RFC 7986) -> CALNAME.
     *
     * @param   string   $ics   Conteudo bruto do arquivo .ics
     *
     * @return  string|null   Nome do calendario ou null se ausente
     */
    public static function extractCalendarName($ics)
    {
        if (!\is_string($ics) || $ics === '') {
            return null;
        }

        $lines     = self::unfoldLines($ics);
        $depthSkip = 0;
        $names     = [
            'X-WR-CALNAME' => null,
            'NAME'         => null,
            'CALNAME'      => null,
        ];

        foreach ($lines as $line) {
            $upper = \strtoupper(\trim($line));

            if ($depthSkip > 0) {
                if (\strpos($upper, 'BEGIN:') === 0) {
                    $depthSkip++;
                } elseif (\strpos($upper, 'END:') === 0) {
                    $depthSkip--;
                }
                continue;
            }

            if ($upper === 'BEGIN:VEVENT') {
                break;
            }

            if (\strpos($upper, 'BEGIN:') === 0) {
                if ($upper === 'BEGIN:VCALENDAR') {
                    continue;
                }

                $depthSkip = 1;
                continue;
            }

            if (\strpos($upper, 'END:') === 0) {
                continue;
            }

            $parsed = self::parseContentLine($line);
            if ($parsed === null) {
                continue;
            }

            $name = $parsed['name'];
            if (!\array_key_exists($name, $names) || $names[$name] !== null) {
                continue;
            }

            $value = self::unescapeText($parsed['value']);
            if ($value !== '') {
                $names[$name] = $value;
            }
        }

        foreach (['X-WR-CALNAME', 'NAME', 'CALNAME'] as $key) {
            if ($names[$key] !== null) {
                return $names[$key];
            }
        }

        return null;
    }

    /**
     * Estrutura bruta acumulada para um VEVENT em parsing.
     *
     * @return  array
     */
    protected static function newRawEvent()
    {
        return [
            'uid'              => '',
            'summary'          => '',
            'description'      => '',
            'description_html' => '',
            'location'         => '',
            'dtstart'     => null,
            'dtend'       => null,
            'duration'    => '',
            'rrule'       => '',
            'exdate'      => [],
            'attachments' => [],
        ];
    }

    /**
     * Acumula uma propriedade do VEVENT na estrutura bruta.
     *
     * @param   array   $event   Estrutura bruta (por referencia)
     * @param   array   $parsed  ['name' => , 'params' => [], 'value' => ]
     *
     * @return  void
     */
    protected static function collectProperty(array &$event, array $parsed)
    {
        $name   = $parsed['name'];
        $params = $parsed['params'];
        $value  = $parsed['value'];

        switch ($name) {
            case 'UID':
                $event['uid'] = \trim($value);
                break;

            case 'SUMMARY':
                $event['summary'] = self::unescapeText($value);
                break;

            case 'DESCRIPTION':
                $event['description'] = self::unescapeText($value);
                break;

            case 'X-ALT-DESC':
                $event['description_html'] = self::parseAltDescription($value, $params);
                break;

            case 'LOCATION':
                $event['location'] = self::unescapeText($value);
                break;

            case 'DTSTART':
                $event['dtstart'] = ['value' => $value, 'params' => $params];
                break;

            case 'DTEND':
                $event['dtend'] = ['value' => $value, 'params' => $params];
                break;

            case 'DURATION':
                $event['duration'] = \trim($value);
                break;

            case 'RRULE':
                $event['rrule'] = \trim($value);
                break;

            case 'EXDATE':
                // EXDATE pode trazer varias datas separadas por virgula
                foreach (\explode(',', $value) as $part) {
                    $part = \trim($part);
                    if ($part !== '') {
                        $event['exdate'][] = ['value' => $part, 'params' => $params];
                    }
                }
                break;

            case 'ATTACH':
                $valueType = isset($params['VALUE']) ? \strtoupper($params['VALUE']) : 'URI';

                if ($valueType === 'BINARY' || isset($params['ENCODING'])) {
                    break;
                }

                $url = self::unescapeText($value);

                if (!self::isValidAttachmentUrl($url)) {
                    break;
                }

                $event['attachments'][] = [
                    'value'  => $url,
                    'params' => $params,
                ];
                break;
        }
    }

    /**
     * Constroi o evento normalizado a partir da estrutura bruta.
     *
     * @param   array          $raw           Estrutura bruta do VEVENT
     * @param   \DateTimeZone  $tz            Timezone destino
     * @param   \DateTime      $horizonEnd    Limite superior para expansao de recorrencias
     * @param   int            $maxOccurrences Trava de seguranca
     *
     * @return  array|null   Evento normalizado ou null se invalido
     */
    protected static function buildEvent(array $raw, \DateTimeZone $tz, \DateTime $horizonEnd, $maxOccurrences)
    {
        // Sem data inicial nao ha evento valido
        if (empty($raw['dtstart'])) {
            return null;
        }

        $start = self::parseDateValue($raw['dtstart']['value'], $raw['dtstart']['params'], $tz);
        if ($start === null) {
            return null;
        }

        $end = self::resolveEnd($raw, $start, $tz);

        $event = [
            'uid'                   => $raw['uid'],
            'event_title'           => $raw['summary'],
            'description'           => $raw['description'],
            'description_html'      => $raw['description_html'],
            'location'              => $raw['location'],
            'all_day'               => $start['all_day'],
            'initial_date'          => $start['date'],
            'final_date'            => $end['date'],
            'hours'                 => [],
            'is_recurrent'          => '0',
            'recurrence_type'       => '',
            'recurrent_days'        => [],
            'recurrent_days_ignore' => [],
            'specific_dates'        => [],
            'attachments'           => $raw['attachments'],
        ];

        // Horarios (apenas para eventos com hora definida)
        if (!$start['all_day'] && $start['time'] !== null) {
            $event['hours'][] = [
                'initial' => $start['time'],
                'final'   => $end['time'] !== null ? $end['time'] : '',
            ];
        }

        // Datas a ignorar (EXDATE) normalizadas no timezone destino
        $exdates = [];
        foreach ($raw['exdate'] as $ex) {
            $parsedEx = self::parseDateValue($ex['value'], $ex['params'], $tz);
            if ($parsedEx !== null) {
                $exdates[$parsedEx['date']] = true;
            }
        }

        // Sem recorrencia: evento simples
        if (empty($raw['rrule'])) {
            $event['recurrent_days_ignore'] = \array_keys($exdates);
            return $event;
        }

        $rrule = self::parseRRule($raw['rrule'], $tz);

        return self::applyRecurrence($event, $start, $rrule, $exdates, $horizonEnd, $maxOccurrences);
    }

    /**
     * Aplica a recorrencia (RRULE) ao evento, mapeando para o modelo do No Boss Calendar.
     *
     * @param   array          $event           Evento normalizado (base)
     * @param   array          $start           Data inicial parseada
     * @param   array          $rrule           Regra parseada
     * @param   array          $exdates         Mapa de datas a excluir ('Y-m-d' => true)
     * @param   \DateTime      $horizonEnd      Limite superior
     * @param   int            $maxOccurrences  Trava de seguranca
     *
     * @return  array
     */
    protected static function applyRecurrence(array $event, array $start, array $rrule, array $exdates, \DateTime $horizonEnd, $maxOccurrences)
    {
        $freq     = $rrule['freq'];
        $interval = $rrule['interval'];
        $byday    = $rrule['byday'];

        $dtStart = clone $start['dt'];
        $dtStart->setTime(0, 0, 0);

        // Mapeamento direto para 'week-days': DAILY/WEEKLY com INTERVAL 1 e BYDAY simples (sem ordinais)
        $simpleByDay = self::byDayHasOrdinal($byday) === false;

        if (\in_array($freq, ['WEEKLY', 'DAILY'], true) && $interval === 1 && $simpleByDay) {
            $event['is_recurrent']    = '1';
            $event['recurrence_type'] = 'week-days';

            // Dias da semana
            $days = self::resolveWeekDays($freq, $byday, $dtStart);
            $event['recurrent_days'] = $days;

            // Data inicial = DTSTART; data final = UNTIL/COUNT/horizonte
            $event['initial_date'] = $dtStart->format('Y-m-d');
            $event['final_date']   = self::resolveWeekDaysFinalDate($rrule, $dtStart, $byday, $freq, $horizonEnd, $maxOccurrences);

            // EXDATE -> dias a ignorar
            $event['recurrent_days_ignore'] = \array_keys($exdates);

            return $event;
        }

        // Demais casos: expande as ocorrencias em datas especificas
        $dates = self::expandOccurrences($freq, $interval, $byday, $rrule, $dtStart, $horizonEnd, $maxOccurrences);

        // Remove as datas excluidas (EXDATE)
        $dates = \array_values(\array_filter($dates, static function ($date) use ($exdates) {
            return !isset($exdates[$date]);
        }));

        // Sem ocorrencias validas: degrada para evento simples (mantem initial/final originais)
        if (empty($dates)) {
            return $event;
        }

        $event['is_recurrent']    = '1';
        $event['recurrence_type'] = 'specific-dates';
        $event['specific_dates']  = $dates;
        $event['initial_date']    = $dates[0];
        $event['final_date']      = $dates[\count($dates) - 1];

        return $event;
    }

    /**
     * Resolve os dias da semana (recurrent_days) para o tipo 'week-days'.
     *
     * @param   string      $freq      FREQ (DAILY|WEEKLY)
     * @param   array       $byday     Lista de tokens BYDAY ja parseados
     * @param   \DateTime   $dtStart   Data inicial
     *
     * @return  array   Mapa indiceDia => 1
     */
    protected static function resolveWeekDays($freq, array $byday, \DateTime $dtStart)
    {
        $days = [];

        if (!empty($byday)) {
            foreach ($byday as $token) {
                if (isset(self::WEEKDAY_MAP[$token['day']])) {
                    $days[self::WEEKDAY_MAP[$token['day']]] = 1;
                }
            }
        } elseif ($freq === 'DAILY') {
            // DAILY sem BYDAY = todos os dias da semana
            for ($i = 0; $i < 7; $i++) {
                $days[$i] = 1;
            }
        } else {
            // WEEKLY sem BYDAY = dia da semana do DTSTART
            $days[(int) $dtStart->format('w')] = 1;
        }

        \ksort($days);

        return $days;
    }

    /**
     * Determina a data final para recorrencia 'week-days'.
     *
     * @param   array       $rrule           Regra parseada
     * @param   \DateTime   $dtStart         Data inicial
     * @param   array       $byday           Tokens BYDAY
     * @param   string      $freq            FREQ
     * @param   \DateTime   $horizonEnd      Limite superior
     * @param   int         $maxOccurrences  Trava de seguranca
     *
     * @return  string   Data final 'Y-m-d'
     */
    protected static function resolveWeekDaysFinalDate(array $rrule, \DateTime $dtStart, array $byday, $freq, \DateTime $horizonEnd, $maxOccurrences)
    {
        // UNTIL definido: usa diretamente
        if ($rrule['until'] instanceof \DateTime) {
            $until = clone $rrule['until'];
            // Nao ultrapassa o horizonte de seguranca
            return ($until < $horizonEnd ? $until : $horizonEnd)->format('Y-m-d');
        }

        // COUNT definido: expande para descobrir a data da ultima ocorrencia
        if ($rrule['count'] !== null) {
            $dates = self::expandOccurrences($freq, 1, $byday, $rrule, $dtStart, $horizonEnd, $maxOccurrences);
            if (!empty($dates)) {
                return $dates[\count($dates) - 1];
            }
        }

        // Sem limite: usa o horizonte default
        return $horizonEnd->format('Y-m-d');
    }

    /**
     * Expande as ocorrencias de uma RRULE em uma lista de datas 'Y-m-d'.
     *
     * @param   string      $freq            FREQ
     * @param   int         $interval        INTERVAL
     * @param   array       $byday           Tokens BYDAY
     * @param   array       $rrule           Regra completa (count/until)
     * @param   \DateTime   $dtStart         Data inicial
     * @param   \DateTime   $horizonEnd      Limite superior
     * @param   int         $maxOccurrences  Trava de seguranca
     *
     * @return  array   Lista ordenada e unica de datas 'Y-m-d'
     */
    protected static function expandOccurrences($freq, $interval, array $byday, array $rrule, \DateTime $dtStart, \DateTime $horizonEnd, $maxOccurrences)
    {
        $interval = \max(1, (int) $interval);
        $count    = $rrule['count'];
        $until     = $rrule['until'] instanceof \DateTime ? clone $rrule['until'] : null;

        // Limite efetivo = menor entre UNTIL e o horizonte
        $limit = clone $horizonEnd;
        if ($until !== null && $until < $limit) {
            $limit = $until;
        }
        $limit->setTime(0, 0, 0);

        $startDate = clone $dtStart;
        $startDate->setTime(0, 0, 0);

        $generated = 0;
        $dates     = [];

        switch ($freq) {
            case 'DAILY':
                $cursor = clone $startDate;
                while ($cursor <= $limit && $generated < $maxOccurrences) {
                    if (empty($byday) || self::weekdayMatchesByDay($cursor, $byday)) {
                        $generated++;
                        if ($count !== null && $generated > $count) {
                            break 2;
                        }
                        $dates[$cursor->format('Y-m-d')] = true;
                    }
                    $cursor->modify('+' . $interval . ' days');
                }
                break;

            case 'WEEKLY':
                // Inicia na segunda-feira (WKST padrao) da semana do DTSTART
                $weekStart = clone $startDate;
                $iso       = (int) $weekStart->format('N'); // 1 (Seg) .. 7 (Dom)
                $weekStart->modify('-' . ($iso - 1) . ' days');

                // Sem BYDAY: usa o dia da semana do DTSTART
                $targets = !empty($byday) ? $byday : [['ordinal' => 0, 'day' => self::isoToIcsDay($iso)]];

                while ($weekStart <= $limit && $generated < $maxOccurrences) {
                    foreach ($targets as $token) {
                        $offset = self::icsDayToIsoOffset($token['day']);
                        if ($offset === null) {
                            continue;
                        }
                        $candidate = clone $weekStart;
                        $candidate->modify('+' . $offset . ' days');

                        if ($candidate < $startDate || $candidate > $limit) {
                            continue;
                        }

                        $generated++;
                        if ($count !== null && $generated > $count) {
                            break 3;
                        }
                        $dates[$candidate->format('Y-m-d')] = true;
                    }
                    $weekStart->modify('+' . ($interval * 7) . ' days');
                }
                break;

            case 'MONTHLY':
                $monthCursor = clone $startDate;
                $monthCursor->modify('first day of this month');
                $monthCursor->setTime(0, 0, 0);

                while ($monthCursor <= $limit && $generated < $maxOccurrences) {
                    $candidates = self::candidatesForMonth($monthCursor, $byday, $startDate);
                    foreach ($candidates as $candidate) {
                        if ($candidate < $startDate || $candidate > $limit) {
                            continue;
                        }
                        $generated++;
                        if ($count !== null && $generated > $count) {
                            break 3;
                        }
                        $dates[$candidate->format('Y-m-d')] = true;
                    }
                    $monthCursor->modify('first day of +' . $interval . ' month');
                }
                break;

            case 'YEARLY':
                $yearCursor = clone $startDate;

                while ($yearCursor <= $limit && $generated < $maxOccurrences) {
                    $candidates = self::candidatesForYear($yearCursor, $byday, $startDate);
                    foreach ($candidates as $candidate) {
                        if ($candidate < $startDate || $candidate > $limit) {
                            continue;
                        }
                        $generated++;
                        if ($count !== null && $generated > $count) {
                            break 3;
                        }
                        $dates[$candidate->format('Y-m-d')] = true;
                    }
                    $yearCursor->modify('+' . $interval . ' year');
                }
                break;
        }

        $dates = \array_keys($dates);
        \sort($dates);

        return $dates;
    }

    /**
     * Gera as datas candidatas dentro de um mes (MONTHLY).
     *
     * @param   \DateTime   $monthStart   Primeiro dia do mes
     * @param   array       $byday        Tokens BYDAY
     * @param   \DateTime   $dtStart      Data inicial (para day-of-month quando sem BYDAY)
     *
     * @return  \DateTime[]
     */
    protected static function candidatesForMonth(\DateTime $monthStart, array $byday, \DateTime $dtStart)
    {
        $year  = (int) $monthStart->format('Y');
        $month = (int) $monthStart->format('n');

        if (empty($byday)) {
            // Mesmo dia do mes do DTSTART (ignora meses sem esse dia, ex.: 31 em fevereiro)
            $day = (int) $dtStart->format('j');
            if ($day > (int) $monthStart->format('t')) {
                return [];
            }
            return [self::makeDate($year, $month, $day)];
        }

        return self::weekdayCandidates($byday, $year, $month);
    }

    /**
     * Gera as datas candidatas dentro de um ano (YEARLY).
     *
     * @param   \DateTime   $yearStart   Referencia do ano
     * @param   array       $byday       Tokens BYDAY
     * @param   \DateTime   $dtStart     Data inicial
     *
     * @return  \DateTime[]
     */
    protected static function candidatesForYear(\DateTime $yearStart, array $byday, \DateTime $dtStart)
    {
        $year  = (int) $yearStart->format('Y');
        $month = (int) $dtStart->format('n');

        if (empty($byday)) {
            $day = (int) $dtStart->format('j');
            $maxDay = (int) self::makeDate($year, $month, 1)->format('t');
            if ($day > $maxDay) {
                return [];
            }
            return [self::makeDate($year, $month, $day)];
        }

        return self::weekdayCandidates($byday, $year, $month);
    }

    /**
     * Calcula as datas de um mes/ano a partir de tokens BYDAY (com ou sem ordinal).
     *
     * @param   array   $byday   Tokens BYDAY
     * @param   int     $year    Ano
     * @param   int     $month   Mes (1-12)
     *
     * @return  \DateTime[]
     */
    protected static function weekdayCandidates(array $byday, $year, $month)
    {
        $result   = [];
        $daysInMonth = (int) self::makeDate($year, $month, 1)->format('t');

        foreach ($byday as $token) {
            if (!isset(self::WEEKDAY_MAP[$token['day']])) {
                continue;
            }

            $targetW  = self::WEEKDAY_MAP[$token['day']]; // 0 (Dom) .. 6 (Sab)
            $matches  = [];

            for ($d = 1; $d <= $daysInMonth; $d++) {
                $date = self::makeDate($year, $month, $d);
                if ((int) $date->format('w') === $targetW) {
                    $matches[] = $date;
                }
            }

            $ordinal = (int) $token['ordinal'];

            if ($ordinal === 0) {
                // Todas as ocorrencias do dia da semana no mes
                foreach ($matches as $m) {
                    $result[$m->format('Y-m-d')] = $m;
                }
            } elseif ($ordinal > 0 && isset($matches[$ordinal - 1])) {
                $m = $matches[$ordinal - 1];
                $result[$m->format('Y-m-d')] = $m;
            } elseif ($ordinal < 0) {
                $index = \count($matches) + $ordinal;
                if (isset($matches[$index])) {
                    $m = $matches[$index];
                    $result[$m->format('Y-m-d')] = $m;
                }
            }
        }

        \ksort($result);

        return \array_values($result);
    }

    /**
     * Resolve a data/hora final do evento a partir de DTEND ou DURATION.
     *
     * @param   array          $raw     Estrutura bruta
     * @param   array          $start   Data inicial parseada
     * @param   \DateTimeZone  $tz      Timezone destino
     *
     * @return  array   ['date' => 'Y-m-d', 'time' => 'HH:MM'|null, 'dt' => \DateTime, 'all_day' => bool]
     */
    protected static function resolveEnd(array $raw, array $start, \DateTimeZone $tz)
    {
        // DTEND explicito
        if (!empty($raw['dtend'])) {
            $end = self::parseDateValue($raw['dtend']['value'], $raw['dtend']['params'], $tz);

            if ($end !== null) {
                // Eventos all-day: DTEND eh exclusivo (dia seguinte ao ultimo) -> subtrai 1 dia
                if ($end['all_day']) {
                    $dt = clone $end['dt'];
                    $dt->modify('-1 day');
                    // Garante coerencia: nunca antes da data inicial
                    if ($dt < $start['dt']) {
                        $dt = clone $start['dt'];
                    }
                    return [
                        'date'    => $dt->format('Y-m-d'),
                        'time'    => null,
                        'dt'      => $dt,
                        'all_day' => true,
                    ];
                }

                return $end;
            }
        }

        // DURATION (calcula fim a partir do inicio)
        if (!empty($raw['duration'])) {
            $dt = self::applyDuration($start['dt'], $raw['duration']);
            if ($dt !== null) {
                if ($start['all_day']) {
                    // Duracao em eventos all-day: fim eh exclusivo -> subtrai 1 dia
                    $dt->modify('-1 day');
                    if ($dt < $start['dt']) {
                        $dt = clone $start['dt'];
                    }
                    return [
                        'date'    => $dt->format('Y-m-d'),
                        'time'    => null,
                        'dt'      => $dt,
                        'all_day' => true,
                    ];
                }

                return [
                    'date'    => $dt->format('Y-m-d'),
                    'time'    => $dt->format('H:i'),
                    'dt'      => $dt,
                    'all_day' => false,
                ];
            }
        }

        // Sem DTEND/DURATION: fim igual ao inicio
        return [
            'date'    => $start['date'],
            'time'    => $start['time'],
            'dt'      => clone $start['dt'],
            'all_day' => $start['all_day'],
        ];
    }

    /**
     * Aplica uma DURATION ISO-8601 (ex.: PT1H30M, P2D) a uma data.
     *
     * @param   \DateTime   $dt        Data base
     * @param   string      $duration  String de duracao
     *
     * @return  \DateTime|null
     */
    protected static function applyDuration(\DateTime $dt, $duration)
    {
        $negative = \strpos($duration, '-') === 0;
        $duration = \ltrim($duration, '+-');

        try {
            $interval = new \DateInterval($duration);
        } catch (\Exception $e) {
            return null;
        }

        $result = clone $dt;
        if ($negative) {
            $interval->invert = 1;
        }
        $result->add($interval);

        return $result;
    }

    /**
     * Faz o parse de um valor de data/hora ICS aplicando timezone.
     *
     * @param   string         $value    Valor (ex.: 20260615, 20260615T140000Z, 20260615T140000)
     * @param   array          $params   Parametros da propriedade (VALUE, TZID)
     * @param   \DateTimeZone  $tz       Timezone destino
     *
     * @return  array|null   ['all_day' => bool, 'date' => 'Y-m-d', 'time' => 'HH:MM'|null, 'dt' => \DateTime]
     */
    protected static function parseDateValue($value, array $params, \DateTimeZone $tz)
    {
        $value = \trim($value);
        if ($value === '') {
            return null;
        }

        $isAllDay = (isset($params['VALUE']) && \strtoupper($params['VALUE']) === 'DATE')
            || \preg_match('/^\d{8}$/', $value) === 1;

        // Data sem horario (all-day)
        if ($isAllDay) {
            if (\preg_match('/^(\d{4})(\d{2})(\d{2})/', $value, $m) !== 1) {
                return null;
            }
            try {
                $dt = new \DateTime($m[1] . '-' . $m[2] . '-' . $m[3] . ' 00:00:00', $tz);
            } catch (\Exception $e) {
                return null;
            }

            return [
                'all_day' => true,
                'date'    => $dt->format('Y-m-d'),
                'time'    => null,
                'dt'      => $dt,
            ];
        }

        // Data com horario
        if (\preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z)?$/', $value, $m) !== 1) {
            return null;
        }

        $dateStr = $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . (isset($m[6]) && $m[6] !== '' ? $m[6] : '00');
        $isUtc   = isset($m[7]) && $m[7] === 'Z';

        // Timezone de origem: Z (UTC), TZID informado, ou flutuante (assume destino)
        if ($isUtc) {
            $sourceTz = new \DateTimeZone('UTC');
        } elseif (!empty($params['TZID'])) {
            try {
                $sourceTz = new \DateTimeZone($params['TZID']);
            } catch (\Exception $e) {
                $sourceTz = $tz;
            }
        } else {
            $sourceTz = $tz;
        }

        try {
            $dt = new \DateTime($dateStr, $sourceTz);
        } catch (\Exception $e) {
            return null;
        }

        // Converte para o timezone destino
        $dt->setTimezone($tz);

        return [
            'all_day' => false,
            'date'    => $dt->format('Y-m-d'),
            'time'    => $dt->format('H:i'),
            'dt'      => $dt,
        ];
    }

    /**
     * Faz o parse de uma RRULE.
     *
     * @param   string         $rrule  Valor bruto da RRULE
     * @param   \DateTimeZone  $tz     Timezone destino (para UNTIL)
     *
     * @return  array   ['freq' =>, 'interval' =>, 'byday' => [['ordinal' =>, 'day' =>]], 'count' =>, 'until' =>]
     */
    protected static function parseRRule($rrule, \DateTimeZone $tz)
    {
        $result = [
            'freq'     => '',
            'interval' => 1,
            'byday'    => [],
            'count'    => null,
            'until'    => null,
        ];

        foreach (\explode(';', $rrule) as $part) {
            $part = \trim($part);
            if ($part === '' || \strpos($part, '=') === false) {
                continue;
            }

            [$key, $val] = \explode('=', $part, 2);
            $key = \strtoupper(\trim($key));
            $val = \trim($val);

            switch ($key) {
                case 'FREQ':
                    $result['freq'] = \strtoupper($val);
                    break;

                case 'INTERVAL':
                    $result['interval'] = \max(1, (int) $val);
                    break;

                case 'COUNT':
                    $result['count'] = \max(0, (int) $val);
                    break;

                case 'UNTIL':
                    $parsed = self::parseDateValue($val, [], $tz);
                    if ($parsed !== null) {
                        $until = clone $parsed['dt'];
                        $until->setTime(0, 0, 0);
                        $result['until'] = $until;
                    }
                    break;

                case 'BYDAY':
                    foreach (\explode(',', $val) as $token) {
                        $token = \strtoupper(\trim($token));
                        if (\preg_match('/^([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/', $token, $m) === 1) {
                            $result['byday'][] = [
                                'ordinal' => $m[1] !== '' ? (int) $m[1] : 0,
                                'day'     => $m[2],
                            ];
                        }
                    }
                    break;
            }
        }

        return $result;
    }

    /**
     * Verifica se algum token BYDAY possui ordinal (ex.: 2MO, -1FR).
     *
     * @param   array   $byday  Tokens BYDAY
     *
     * @return  bool
     */
    protected static function byDayHasOrdinal(array $byday)
    {
        foreach ($byday as $token) {
            if ((int) $token['ordinal'] !== 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * Verifica se o dia da semana de uma data esta entre os tokens BYDAY.
     *
     * @param   \DateTime   $date   Data
     * @param   array       $byday  Tokens BYDAY
     *
     * @return  bool
     */
    protected static function weekdayMatchesByDay(\DateTime $date, array $byday)
    {
        $w = (int) $date->format('w');
        foreach ($byday as $token) {
            if (isset(self::WEEKDAY_MAP[$token['day']]) && self::WEEKDAY_MAP[$token['day']] === $w) {
                return true;
            }
        }

        return false;
    }

    /**
     * Converte um indice ISO (1=Seg..7=Dom) para a sigla ICS (MO..SU).
     *
     * @param   int   $iso
     *
     * @return  string
     */
    protected static function isoToIcsDay($iso)
    {
        $map = [1 => 'MO', 2 => 'TU', 3 => 'WE', 4 => 'TH', 5 => 'FR', 6 => 'SA', 7 => 'SU'];

        return $map[$iso] ?? 'MO';
    }

    /**
     * Retorna o offset (em dias) de uma sigla ICS a partir da segunda-feira (WKST padrao).
     *
     * @param   string   $day  Sigla ICS (MO..SU)
     *
     * @return  int|null
     */
    protected static function icsDayToIsoOffset($day)
    {
        $map = ['MO' => 0, 'TU' => 1, 'WE' => 2, 'TH' => 3, 'FR' => 4, 'SA' => 5, 'SU' => 6];

        return $map[$day] ?? null;
    }

    /**
     * Cria um \DateTime a partir de ano/mes/dia (meia-noite, timezone default do PHP).
     *
     * @param   int   $year
     * @param   int   $month
     * @param   int   $day
     *
     * @return  \DateTime
     */
    protected static function makeDate($year, $month, $day)
    {
        $dt = new \DateTime();
        $dt->setDate((int) $year, (int) $month, (int) $day);
        $dt->setTime(0, 0, 0);

        return $dt;
    }

    /**
     * Resolve o horizonte superior para expansao de recorrencias.
     *
     * @param   mixed          $value  String, \DateTime ou null
     * @param   \DateTimeZone  $tz     Timezone destino
     *
     * @return  \DateTime
     */
    protected static function resolveHorizonEnd($value, \DateTimeZone $tz)
    {
        if ($value instanceof \DateTime) {
            $dt = clone $value;
            $dt->setTime(0, 0, 0);
            return $dt;
        }

        if (\is_string($value) && $value !== '') {
            try {
                $dt = new \DateTime($value, $tz);
                $dt->setTime(0, 0, 0);
                return $dt;
            } catch (\Exception $e) {
                // cai no default abaixo
            }
        }

        $dt = new \DateTime('now', $tz);
        $dt->modify('+' . self::DEFAULT_HORIZON_YEARS . ' year');
        $dt->setTime(0, 0, 0);

        return $dt;
    }

    /**
     * Faz o unfolding das linhas do ICS (RFC 5545): linhas iniciadas por espaco/tab sao continuacoes.
     *
     * @param   string   $ics  Conteudo bruto
     *
     * @return  array   Linhas logicas (ja desdobradas)
     */
    protected static function unfoldLines($ics)
    {
        // Remove BOM eventual
        $ics = \preg_replace('/^\xEF\xBB\xBF/', '', $ics);

        // Normaliza quebras de linha
        $ics = \str_replace(["\r\n", "\r"], "\n", $ics);

        $rawLines = \explode("\n", $ics);
        $lines    = [];

        foreach ($rawLines as $line) {
            // Continuacao: comeca com espaco ou tab -> concatena a linha anterior
            if ($line !== '' && ($line[0] === ' ' || $line[0] === "\t")) {
                if (!empty($lines)) {
                    $lines[\count($lines) - 1] .= \substr($line, 1);
                }
                continue;
            }

            $lines[] = $line;
        }

        return $lines;
    }

    /**
     * Faz o parse de uma linha de conteudo ICS em nome, parametros e valor.
     *
     * @param   string   $line  Linha logica
     *
     * @return  array|null   ['name' =>, 'params' => [], 'value' =>]
     */
    protected static function parseContentLine($line)
    {
        if (\trim($line) === '') {
            return null;
        }

        // Localiza o primeiro ':' fora de aspas duplas (separa name+params do value)
        $colonPos = self::findValueColon($line);
        if ($colonPos === null) {
            return null;
        }

        $namePart = \substr($line, 0, $colonPos);
        $value    = \substr($line, $colonPos + 1);

        // Separa nome e parametros (separados por ';')
        $segments = self::splitParams($namePart);
        $name     = \strtoupper(\trim(\array_shift($segments)));

        $params = [];
        foreach ($segments as $segment) {
            if (\strpos($segment, '=') === false) {
                continue;
            }
            [$pKey, $pVal] = \explode('=', $segment, 2);
            $pKey = \strtoupper(\trim($pKey));
            $pVal = \trim($pVal);
            // Remove aspas dos valores de parametro
            if (\strlen($pVal) >= 2 && $pVal[0] === '"' && \substr($pVal, -1) === '"') {
                $pVal = \substr($pVal, 1, -1);
            }
            $params[$pKey] = $pVal;
        }

        return [
            'name'   => $name,
            'params' => $params,
            'value'  => $value,
        ];
    }

    /**
     * Encontra a posicao do ':' que separa a parte de nome/params do valor (ignora ':' dentro de aspas).
     *
     * @param   string   $line  Linha logica
     *
     * @return  int|null
     */
    protected static function findValueColon($line)
    {
        $inQuotes = false;
        $len      = \strlen($line);

        for ($i = 0; $i < $len; $i++) {
            $char = $line[$i];
            if ($char === '"') {
                $inQuotes = !$inQuotes;
            } elseif ($char === ':' && !$inQuotes) {
                return $i;
            }
        }

        return null;
    }

    /**
     * Divide a parte de nome/parametros por ';' ignorando separadores dentro de aspas.
     *
     * @param   string   $namePart
     *
     * @return  array
     */
    protected static function splitParams($namePart)
    {
        $segments = [];
        $buffer   = '';
        $inQuotes = false;
        $len      = \strlen($namePart);

        for ($i = 0; $i < $len; $i++) {
            $char = $namePart[$i];
            if ($char === '"') {
                $inQuotes = !$inQuotes;
                $buffer  .= $char;
            } elseif ($char === ';' && !$inQuotes) {
                $segments[] = $buffer;
                $buffer     = '';
            } else {
                $buffer .= $char;
            }
        }

        $segments[] = $buffer;

        return $segments;
    }

    /**
     * Extrai HTML alternativo de X-ALT-DESC (FMTTYPE=text/html).
     *
     * @param   string   $value    Valor bruto da propriedade.
     * @param   array    $params   Parametros da propriedade (FMTTYPE, ENCODING, etc.).
     *
     * @return  string
     */
    protected static function parseAltDescription($value, array $params)
    {
        $fmtType = isset($params['FMTTYPE']) ? \strtolower(\trim((string) $params['FMTTYPE'])) : '';

        if ($fmtType !== 'text/html') {
            return '';
        }

        $encoding = isset($params['ENCODING']) ? \strtoupper(\trim((string) $params['ENCODING'])) : '';
        $raw      = \trim((string) $value);

        if ($raw === '') {
            return '';
        }

        if ($encoding === 'BASE64') {
            $decoded = \base64_decode($raw, true);
            if ($decoded === false) {
                return '';
            }
            $raw = $decoded;
        }

        return self::unescapeText($raw);
    }

    /**
     * Decodifica os escapes de texto do RFC 5545 (\n, \,, \;, \\).
     *
     * @param   string   $value
     *
     * @return  string
     */
    protected static function unescapeText($value)
    {
        $replacements = [
            '\\n'  => "\n",
            '\\N'  => "\n",
            '\\,'  => ',',
            '\\;'  => ';',
            '\\\\' => '\\',
        ];

        return \strtr(\trim($value), $replacements);
    }

    /**
     * Converte anexos brutos do VEVENT (ATTACH) para o JSON usado na coluna attachments.
     *
     * @param   array   $rawAttachments   Lista [['value' => url, 'params' => []], ...]
     *
     * @return  array   Objetos com fileUrl, title, mimeType e iconLink (paridade Google)
     */
    public static function formatAttachmentsForStorage(array $rawAttachments)
    {
        $formatted = [];

        foreach ($rawAttachments as $item) {
            $url    = isset($item['value']) ? \trim((string) $item['value']) : '';
            $params = isset($item['params']) && \is_array($item['params']) ? $item['params'] : [];

            if (!self::isValidAttachmentUrl($url)) {
                continue;
            }

            $mimeType = isset($params['FMTTYPE']) ? \trim((string) $params['FMTTYPE']) : '';
            $title    = self::resolveAttachmentTitle($params, $url);

            $attachment = [
                'fileUrl'  => $url,
                'title'    => $title,
                'iconLink' => self::buildAttachmentIconLink($mimeType),
            ];

            if ($mimeType !== '') {
                $attachment['mimeType'] = $mimeType;
            }

            $formatted[] = $attachment;
        }

        return $formatted;
    }

    /**
     * Valida URL de anexo ICS (somente http/https).
     *
     * @param   string   $url
     *
     * @return  bool
     */
    protected static function isValidAttachmentUrl($url)
    {
        if ($url === '' || \filter_var($url, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        $scheme = \parse_url($url, PHP_URL_SCHEME);

        return \in_array(\strtolower((string) $scheme), ['http', 'https'], true);
    }

    /**
     * Resolve titulo do anexo a partir de FILENAME ou basename da URL.
     *
     * @param   array   $params   Parametros do ATTACH
     * @param   string  $url      URL do anexo
     *
     * @return  string
     */
    protected static function resolveAttachmentTitle(array $params, $url)
    {
        if (!empty($params['FILENAME'])) {
            return \trim((string) $params['FILENAME']);
        }

        $path = (string) \parse_url($url, PHP_URL_PATH);
        $base = \basename($path);

        if ($base !== '' && $base !== '/') {
            return \urldecode($base);
        }

        return 'Anexo';
    }

    /**
     * Gera iconLink (data URI SVG) conforme mimeType do anexo.
     *
     * @param   string   $mimeType
     *
     * @return  string
     */
    protected static function buildAttachmentIconLink($mimeType = '')
    {
        $mimeType = \strtolower(\trim((string) $mimeType));

        if (\strpos($mimeType, 'pdf') !== false) {
            $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="2" fill="#dc3545"/><text x="8" y="11" text-anchor="middle" fill="#fff" font-size="6" font-family="sans-serif">PDF</text></svg>';
        } elseif (\strpos($mimeType, 'image/') === 0) {
            $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="2" fill="#0d6efd"/><circle cx="5.5" cy="5.5" r="1.5" fill="#fff"/><path d="M2 12l3.5-3.5 2 2L11 5.5 14 12H2z" fill="#fff"/></svg>';
        } else {
            $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="2" fill="#6c757d"/><path d="M5 4h4l2 2v6H5V4z" fill="#fff"/><path d="M9 4v2h2" fill="none" stroke="#6c757d" stroke-width="1"/></svg>';
        }

        return 'data:image/svg+xml;base64,' . \base64_encode($svg);
    }

    /**
     * Obtem o timezone configurado no Joomla (com fallback).
     *
     * @return  string
     */
    protected static function getJoomlaTimezone()
    {
        try {
            $config = Factory::getApplication()->getConfig();
            return $config->get('offset', 'America/Sao_Paulo');
        } catch (\Throwable $e) {
            return 'America/Sao_Paulo';
        }
    }
}