<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Support class for generating ical items Note - this code is based on the ical code from mod_facetoface
 *
 * @package mod_booking
 * @copyright 2023 Wunderbyte GmbH <info@wunderbyte.at>
 * @author Davo Smith, Synergy Learning, Andras Princic, David Bogner
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace mod_booking;

use mod_booking\output\description\description_ical;

/**
 * MOD_BOOKING_DESCRIPTION_ICAL
 *
 * @var int
 */
const MOD_BOOKING_DESCRIPTION_ICAL = 3;

/**
 * Class for generating ical items Note - this code is based on the ical code from mod_facetoface
 *
 * @package mod_booking
 * @copyright 2023 Wunderbyte GmbH <info@wunderbyte.at>
 * @author Davo Smith, Synergy Learning, Andras Princic, David Bogner
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class ical {
    /**
     * $datesareset
     *
     * @var bool
     */
    private $datesareset = false;

    /**
     * $booking
     *
     * @var mixed
     */
    protected $booking;

    /**
     * $option
     *
     * @var mixed
     */
    protected $option;

    /**
     * $user
     *
     * @var mixed
     */
    protected $user;

    /**
     * $fromuser
     *
     * @var mixed
     */
    protected $fromuser;

    /**
     * $updated
     *
     * @var mixed
     */
    protected $updated;

    /**
     * $tempfilename
     *
     * @var string
     */
    protected $tempfilename = '';

    /**
     * $times
     *
     * @var array
     */
    protected $times = '';

    /**
     * $ical
     *
     * @var string
     */
    protected $ical = '';

    /**
     * $dtstamp
     *
     * @var string
     */
    protected $dtstamp = '';

    /**
     * $summary
     *
     * @var string
     */
    protected $summary = '';

    /**
     * $description
     *
     * @var string
     */
    protected $description = '';

    /**
     * $location
     *
     * @var string
     */
    protected $location = '';

    /**
     * $host
     *
     * @var string
     */
    protected $host = '';

    /**
     * $status
     *
     * @var string
     */
    protected $status = '';

    /**
     * $role
     *
     * @var string
     */
    protected $role = 'REQ-PARTICIPANT';

    /**
     * $partstat
     *
     * @var string
     */
    protected $partstat = 'NEEDS-ACTION';

    /**
     * $userfullname
     *
     * @var string
     */
    protected $userfullname = '';

    /**
     * $individualvevents
     *
     * @var array
     */
    protected $individualvevents = [];

    /**
     * Create a new mod_booking\ical instance
     *
     * @param object $booking the booking activity details
     * @param object $option the option that is being booked
     * @param object $user the user the booking is for
     * @param object $fromuser
     * @param bool $updated if set to true, this will create an update ical
     */
    public function __construct($booking, $option, $user, $fromuser, $updated = false) {
        global $DB, $CFG;

        $settings = singleton_service::get_instance_of_booking_option_settings($option->id);

        $this->booking = $booking;
        $this->option = $option;
        $this->fromuser = $fromuser;
        $this->updated = $updated;
        $this->times = $DB->get_records(
            'booking_optiondates',
            ['optionid' => $option->id],
            'coursestarttime ASC'
        );
        // Check if start and end dates exist.
        $coursedates = ($this->option->coursestarttime && $this->option->courseendtime);
        $sessiontimes = !empty($this->times);
        // NOTE: Newlines are meant to be encoded with the literal sequence
        // '\n'. But evolution presents a single line text field for location,
        // and shows the newlines as [0x0A] junk. So we switch it for commas
        // here. Remember commas need to be escaped too.
        if ($this->option->courseid && (\get_config('booking', 'icalfieldlocation') == 1)) {
            $url = new \moodle_url('/course/view.php', ['id' => $this->option->courseid]);
            $this->location = $this->escape($url->out());
        } else if (\get_config('booking', 'icalfieldlocation') == 2) {
            $this->location = $this->option->location;
        } else if (\get_config('booking', 'icalfieldlocation') == 3) {
            $this->location = $this->option->institution;
        } else if (\get_config('booking', 'icalfieldlocation') == 4) {
            $this->location = $this->option->address;
        }
        if (($coursedates || $sessiontimes)) {
            $this->datesareset = true;
            $this->user = $DB->get_record('user', ['id' => $user->id]);
            $now = time();
            $this->dtstamp = $this->generate_timestamp($now);
            $this->summary = $this->escape($settings->get_title_with_prefix());
            $this->description = $this->escape($settings->description ?? '', true);
            $urlbits = parse_url($CFG->wwwroot);
            $this->host = $urlbits['host'];
            $this->userfullname = \fullname($this->user);
        }
    }

    /**
     * Create attachments to add to the notification email.
     *
     * @param bool $cancel optional - true to generate a 'cancel' ical event
     * @return array with filename as key and field path as value empty array if no dates are set
     */
    public function get_attachments($cancel = false): array {
        if (!$this->datesareset) {
            return [];
        }

        if ($cancel) {
            $this->role = 'NON-PARTICIPANT';
            $this->partstat = 'DECLINED';
            $this->status = "\nSTATUS:CANCELLED";
            // Determine the correct iCal method.
            $icalmethod = 'CANCEL';
        } else {
            $icalmethod = 'REQUEST';
        }

        // This is where we attach the iCal.
        if (!empty($this->times)) {
            $this->get_vevents_from_optiondates();
        }

        $allvevents = trim(implode("\r\n", $this->individualvevents));
        $icaldata = $this->generate_ical_string($icalmethod, $allvevents);
        $filepathname = $this->generate_tempfile($icaldata);
        return ['booking.ics' => $filepathname];
    }

    /**
     * Generate temporary ical file and return path to tempfile
     *
     * @param string $icaldata ical conform string
     * @return string path to tempfile
     */
    protected function generate_tempfile($icaldata) {
        global $CFG;
        $this->tempfilename = md5($icaldata . microtime());
        $tempfilepathname = $CFG->tempdir . '/' . $this->tempfilename;
        file_put_contents($tempfilepathname, $icaldata);
        return $tempfilepathname;
    }

    /**
     * Generate ical data for ical.ics conform string
     *
     * @param string $icalmethod
     * @param string $vevents
     * @return string ical
     */
    protected function generate_ical_string($icalmethod, $vevents) {
        $icalparts = [
            'BEGIN:VCALENDAR',
            'VERSION:2.0',
            'METHOD:' . $icalmethod,
            'PRODID:Data::ICal 0.22',
            'CALSCALE:GREGORIAN',
            $vevents,
            'END:VCALENDAR',
        ];
        return implode("\r\n", $icalparts);
    }

    /**
     * Get vevents based on session times that are defined in the booking options.
     *
     * @return void
     */
    protected function get_vevents_from_optiondates() {
        global $CFG;
        $vevents = [];
        foreach ($this->times as $time) {
            $dtstart = $this->generate_timestamp($time->coursestarttime);
            $dtend = $this->generate_timestamp($time->courseendtime);
            $uid = md5($CFG->siteidentifier . $time->id . $this->option->id . 'mod_booking_option') . '@' . $this->host;
            $this->add_vevent($uid, $dtstart, $dtend, $time);
        }
    }

    /**
     * Add vevent data to ical string
     *
     * @param string $uid
     * @param string $dtstart
     * @param string $dtend
     * @param bool $time
     * @return void
     */
    protected function add_vevent($uid, $dtstart, $dtend, $time = false) {
        global $CFG, $DB, $PAGE;

        $eventid = false;
        if ($time) {
            // If it's an option date (a session), use the option date's eventid.
            $descriptionical = new description_ical($this->option->id);
            $fulldescription = $descriptionical->render();
        } else {
            // Use calendarid of the option if it's an option event.
            $descriptionical = new description_ical($this->option->id);
            $fulldescription = $descriptionical->render();
        }

        // Make sure we have not tags in full description.
        $fulldescriptionhtml = $fulldescription;
        // Remove CR and CRLF from description as the description must be on one line.
        $fulldescriptionhtml = str_replace(["\r\n", "\n", "\r"], ' ', $fulldescriptionhtml);

        // Check for a url and render it as a nice link.
        // Regular Expression Pattern for a basic URL.
        $pattern = '/\b(?:https?:\/\/)[a-zA-Z0-9\.\-]+(?:\.[a-zA-Z]{2,})(?:\/\S*)?/';
        // Array to hold the matched URLs.
        $matches = [];
        // Perform the pattern match.
        preg_match_all($pattern, $fulldescriptionhtml, $matches);

        foreach ($matches[0] as $url) {
            $fulldescriptionhtml = str_replace($url, '<a href="' . $url . '">Link</a>', $fulldescriptionhtml);
        }
        // Limit line length to 75 characters.
        $fulldescriptionhtml = $this->fold_html_line("X-ALT-DESC;FMTTYPE=text/html:" . $fulldescriptionhtml);

        $fulldescription = rtrim(strip_tags(preg_replace("/<br>|<\/p>/", "\n", $fulldescription)));
        $fulldescription = str_replace("\n", "\\n", $fulldescription);

        // Remove CR and CRLF from description as the description must be on one line to work with ical.
        $fulldescription = str_replace(["\r\n", "\n", "\r"], ' ', $fulldescription);
        // Limit line length to 75 characters.
        $fulldescription = $this->fold_line("DESCRIPTION:" . $fulldescription);

        // Make sure that we fall back onto some reasonable no-reply address.
        $noreplyaddressdefault = 'noreply@' . get_host_from_url($CFG->wwwroot);
        $noreplyaddress = empty($CFG->noreplyaddress) ? $noreplyaddressdefault : $CFG->noreplyaddress;

        // If no bookingmanager was set, we fall back to the no-reply address.
        $fromuseremail = empty($this->fromuser->email) ? $noreplyaddress : $this->fromuser->email;
        if (!empty($this->fromuser->firstname) || !empty($this->fromuser->lastname)) {
            $fromusername = "{$this->fromuser->firstname} {$this->fromuser->lastname}";
        } else {
            $fromusername = "{$CFG->wwwroot}";
        }

        // Fold the attendee line if it is more than 75 characters long.
        $attendee = "ATTENDEE;CUTYPE=INDIVIDUAL;ROLE={$this->role};PARTSTAT={$this->partstat};RSVP=TRUE;" .
                "CN={$this->userfullname};LANGUAGE=en:MAILTO:{$this->user->email}";
        // The fold_line function keeps the ATTENDEE line valid by adding a space at the start of the next line
        // whenever the line breaks.
        $attendee = $this->fold_line($attendee);

        $veventparts = [
            "BEGIN:VEVENT",
            "CLASS:PUBLIC",
            "{$fulldescription}",
            "{$fulldescriptionhtml}",
            "DTEND:{$dtend}",
            "DTSTAMP:{$this->dtstamp}",
            "DTSTART:{$dtstart}",
            "PRIORITY:5",
            "SUMMARY:{$this->summary}",
            "TRANSP:OPAQUE{$this->status}",
            "ORGANIZER;CN={$fromusername}:MAILTO:{$fromuseremail}",
            "{$attendee}",
            "UID:{$uid}",
        ];

        if (!empty($this->location)) {
            $veventparts[] = "LOCATION:{$this->location}";
        }

        // If the event has been updated then add the sequence value before END:VEVENT.
        if ($this->updated) {
            if (!$data = $DB->get_record('booking_icalsequence', ['userid' => $this->user->id, 'optionid' => $this->option->id])) {
                $data = new \stdClass();
                $data->userid = $this->user->id;
                $data->optionid = $this->option->id;
                $data->sequencevalue = 2;
                $DB->insert_record('booking_icalsequence', $data);
                $sequencevalue = $data->sequencevalue;
            } else {
                ++$data->sequencevalue;
                $DB->update_record('booking_icalsequence', $data);
                $sequencevalue = $data->sequencevalue;
            }
        } else {
            $sequencevalue = 1;
        }
        array_push($veventparts, "SEQUENCE:$sequencevalue");
        array_push($veventparts, "END:VEVENT");

        $vevent = implode("\r\n", $veventparts);
        $this->individualvevents[] = $vevent;
    }

    /**
     * Filename of attached ical file.
     *
     * @return string
     */
    public function get_name() {
        return 'booking.ics';
    }

    /**
     * Format timestamp.
     * @param int $timestamp
     * @return string
     */
    protected function generate_timestamp($timestamp) {
        return gmdate('Ymd', $timestamp) . 'T' . gmdate('His', $timestamp) . 'Z';
    }

    /**
     * String escape
     *
     * @param string $text
     * @param bool $converthtml
     *
     * @return string
     *
     */
    protected function escape($text, $converthtml = false) {
        if (empty($text)) {
            return '';
        }

        if ($converthtml) {
            $text = html_to_text($text);
        }

        $text = str_replace(['\\', "\n", ';', ','], ['\\\\', '\n', '\;', '\,'], $text);

        // Text should be wordwrapped at 75 octets, and there should be one whitespace after the newline that does the wrapping.
        $text = wordwrap($text, 75, "\n ", true);

        return $text;
    }

    /**
     * Fold a single iCalendar content line to <=75 octets using RFC 5545 folding.
     * Preserves UTF-8 characters (does not split mid-char) and adds CRLF + space.
     * @param string $line
     * @param int $limit
     * @return string
     */
    public function fold_line(string $line, int $limit = 75): string {
        $out = '';
        $bytes = 0;
        $chunk = '';

        // Iterate by Unicode chars but track byte length.
        $len = mb_strlen($line, 'UTF-8');
        for ($i = 0; $i < $len; $i++) {
            $ch = mb_substr($line, $i, 1, 'UTF-8');
            $chbytes = strlen($ch); // Bytes for this char in UTF-8.

            if ($bytes + $chbytes >= $limit) {
                // Emit current chunk, then start a new folded line.
                $out .= $chunk . "\r\n" . ' ';
                $chunk = $ch;
                $bytes = $chbytes + 1; // Account for the leading space on next physical line.
            } else {
                $chunk .= $ch;
                $bytes += $chbytes;
            }
        }
        return $out . $chunk;
    }

    /**
     * Fold a long iCalendar HTML line (X-ALT-DESC) without breaking tags or URLs.
     *
     * - Ensures each physical line is <=75 octets (including the leading space on
     *   continuation lines).
     * - Prefers folding at spaces or after '>' (end of tag).
     * - Avoids breaking inside "http://", "https://".
     * - Uses CRLF line endings.
     *
     * @param string $line
     * @param int $limit
     * @return string
     */
    public function fold_html_line(string $line, int $limit = 75): string {
        $encoding = 'UTF-8';
        $out = '';
        $offset = 0;
        $first = true;
        $total = strlen($line);

        while ($offset < $total) {
            $maxcontent = $first ? $limit : $limit - 1; // Continuation reserves 1 byte for space.
            if (($total - $offset) <= $maxcontent) {
                // Last chunk fits.
                $chunk = substr($line, $offset);
                $offset = $total;
            } else {
                // Propose a chunk.
                $chunk = mb_strcut($line, $offset, $maxcontent, $encoding);

                // Look for safe break position inside chunk.
                $safepos = max(
                    strrpos($chunk, ' '), // Last space.
                    strrpos($chunk, '>') // Or after tag.
                );

                // Avoid cutting inside URLs.
                $httppos = strrpos($chunk, 'http');
                if ($httppos !== false && $httppos > $safepos) {
                    $safepos = strrpos(substr($chunk, 0, $httppos), ' ');
                }

                if ($safepos !== false && $safepos > 0) {
                    $chunk = substr($chunk, 0, $safepos + 1); // Keep delimiter.
                }
                $offset += strlen($chunk);
            }

            if ($first) {
                $out .= $chunk;
                $first = false;
            } else {
                $out .= "\r\n" . ' ' . $chunk;
            }
        }
        return $out;
    }

    /**
     * Returns the dates records from the optiondates table.
     * @return array
     */
    public function get_times(): array {
        return $this->times;
    }
}
