<?php
/**
 * @package     VikBooking
 * @subpackage  com_vikbooking
 * @author      Alessio Gaggii - E4J srl
 * @copyright   Copyright (C) 2025 E4J srl. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE
 * @link        https://vikwp.com
 */

defined('ABSPATH') or die('No script kiddies please!');

/**
 * PMS Report abstract-parent class that all drivers should extend.
 */
#[\AllowDynamicProperties]
abstract class VikBookingReport
{
    /**
     * @var     string
     */
    protected $reportName = '';

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

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

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

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

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

    /**
     * @var     object
     */
    protected $dbo;

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

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

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

    /**
     * An array of custom options to be passed to the report.
     * Reports can use them before generating the report data.
     * 
     * @var     array
     * 
     * @since   1.15.0 (J) - 1.5.0 (WP)
     */
    protected $options = [];

    /**
     * @var     resource
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    protected $fp_export = null;

    /**
     * @var     string
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    protected $csv_export_format = 'csv';

    /**
     * @var     string
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    protected $csv_export_fname = '';

    /**
     * @var     array
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    protected $actionData = [];

    /**
     * @var     array
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    protected $resourceFiles = [];

    /**
     * @var     ?string
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    protected $scope = null;

    /**
     * @var     array
     * 
     * @since   1.18.6 (J) - 1.8.6 (WP)
     */
    protected $reportRowClasses = [];

    /**
     * Class constructor should define the name of
     * the report and the filters to be displayed.
     */
    public function __construct()
    {
        $this->dbo = JFactory::getDbo();
    }

    /**
     * Extending Classes should define this method
     * to get the name of the report.
     */
    abstract public function getName();

    /**
     * Extending Classes should define this method
     * to get the name of class file.
     */
    abstract public function getFileName();

    /**
     * Extending Classes should define this method
     * to get the filters of the report.
     */
    abstract public function getFilters();

    /**
     * Extending Classes should define this method
     * to generate the report data (cols and rows).
     */
    abstract public function getReportData();

    /**
     * Allows the report to perform a preflight compatibility check with the environment.
     * 
     * @return  ?bool   False if the report is not compatible with the current environment.
     * 
     * @since   1.18.5 (J) - 1.8.5 (WP)
     */
    public function preflight()
    {
        return true;
    }

    /**
     * Allows the report to define the sub-filters template.
     * 
     * @return  ?string
     * 
     * @since   1.18.6 (J) - 1.8.6 (WP)
     */
    public function getSubFiltersTpl()
    {
        return null;
    }

    /**
     * Allows reports to define the default layout type to display.
     * 
     * @param   bool    $hasChart   True if the report has defined a chart.
     * 
     * @return  string
     * 
     * @since   1.18.6 (J) - 1.8.6 (WP)
     */
    public function getDefaultLayoutType(bool $hasChart = false)
    {
        return $hasChart ? 'sheetnchart' : 'sheet';
    }

    /**
     * Returns the AJAX endpoint URL for the current report.
     * 
     * @return  string
     * 
     * @since   1.18.6 (J) - 1.8.6 (WP)
     */
    public function getAjaxUrl()
    {
        return VikBooking::ajaxUrl('index.php?option=com_vikbooking&task=invoke_report&report=' . $this->reportFile);
    }

    /**
     * Main method to generate the report columns and rows. Stores on the current
     * resource the CSV lines and forces the browser to download the file. In case
     * of errors, the process is not terminated to let the View display the errors.
     * 
     * @return  void|bool   script termination on success, false otherwise.
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP) drivers no longer need to implement it,
     *                                  unless the driver needs to override it.
     */
    public function exportCSV()
    {
        // grab all report lines to export
        $csvlines = $this->getExportCSVLines();

        if (!$csvlines) {
            // no data to export
            return false;
        }

        // force the download of the CSV file
        $this->outputHeaders();

        /**
         * Trigger event to allow third-party plugins to set additional headers or contents to output.
         * 
         * @since   1.16.8 (J) - 1.6.8 (WP)
         */
        VBOFactory::getPlatform()->getDispatcher()->trigger('onBeforeExportOutputCSV', [$this, $this->csv_export_format]);

        // send lines to output
        $this->outputCSV($csvlines);

        exit;
    }

    /**
     * Returns the name to give to the CSV (or custom) file being exported.
     * 
     * @param   bool    $cut_suffix     if true the file name suffix will be cut off.
     * @param   string  $suffix         the optional file name suffix, hence the extension.
     * @param   bool    $pretty         if true, dashes will be converted to spaces and more.
     * 
     * @return  string
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    public function getExportCSVFileName($cut_suffix = false, $suffix = '.csv', $pretty = false)
    {
        if ($this->csv_export_fname) {
            // use the exact report file name set
            $export_fname = $this->csv_export_fname;
        } else {
            // use a generic file name
            $export_fname = date('Y-m-d_H.i.s') . '-' . $this->reportFile . '.csv';
        }

        if ($cut_suffix) {
            // cut off the file name suffix
            if (!$suffix) {
                // detect file extension
                $suffix = substr($export_fname, strrpos($export_fname, '.'));
            }
            $export_fname = basename($export_fname, $suffix);
        }

        if ($pretty) {
            // get the default date separator char
            $datesep = VikBooking::getDateSeparator();
            // add a dash between a range of dates
            $export_fname = preg_replace("/([0-9])-([0-9])/i", '$1 - $2', $export_fname);
            // add an empty space between report name and channel name
            $export_fname = preg_replace("/([A-Z])-([A-Z])/i", '$1 $2', $export_fname);
            // add an empty space between report name and date
            $export_fname = preg_replace("/([A-Z0-9])-([0-9])/i", '$1 $2', $export_fname);
            // convert the dashes to the actual date separator char
            $export_fname = preg_replace("/([0-9])_([0-9])/i", '$1' . $datesep . '$2', $export_fname);
        }

        return $export_fname;
    }

    /**
     * Sets the name to give to the CSV (or custom) file being exported.
     * 
     * @param   string  $fname  the full name of the file to export.
     * 
     * @return  self
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    public function setExportCSVFileName($fname)
    {
        $this->csv_export_fname = (string)$fname;

        return $this;
    }

    /**
     * Builds the list of the CSV lines to be exported from the current report data.
     * 
     * @param   bool    $no_data    true for actually not letting the report run.
     * 
     * @return  array               list of CSV lines containing lists of CSV fields.
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    public function getExportCSVLines($no_data = false)
    {
        if (!$no_data && !$this->getReportData()) {
            // nothing to export
            return [];
        }

        // list of CSV lines
        $csvlines = [];

        if (!strcasecmp($this->csv_export_format, 'excel')) {
            // add instructions for Excel

            // UTF-8 BOM (not needed because we convert the encoding to UTF-16LE, which has BOM)
            // $csvlines[] = chr(0xEF) . chr(0xBB) . chr(0xBF);

            // define the separator
            $csvlines[] = "sep=;\n";
        }

        // push the head of the CSV file
        $csvcols = [];
        foreach ($this->cols as $col) {
            if (!is_array($col) || isset($col['ignore_export'])) {
                // skip column
                continue;
            }

            // push column label
            $csvcols[] = $this->encodeExportCSVField($col['label']);
        }

        // push all columns
        $csvlines[] = $csvcols;

        // push the rows + footer row of the CSV file
        foreach (array_merge($this->rows, $this->footerRow) as $row) {
            $csvrow = [];
            foreach ($row as $field) {
                if (!is_array($field) || isset($field['ignore_export'])) {
                    // skip value
                    continue;
                }

                // build value for export
                $export_value = $field['value'];
                if (!isset($field['no_export_callback']) && !isset($field['no_csv_callback'])) {
                    if (is_callable($field['export_callback'] ?? null)) {
                        // trigger closure callback to prepare the value for export
                        $export_value = $field['export_callback']($field['value']);
                    } elseif (is_callable($field['callback_export'] ?? null)) {
                        // trigger closure callback to prepare the value for export
                        $export_value = $field['callback_export']($field['value']);
                    } elseif (is_callable($field['callback'] ?? null)) {
                        // trigger closure callback to prepare the value for export
                        $export_value = $field['callback']($field['value']);
                    }
                }

                // ensure no HTML is present
                $export_value = strip_tags($export_value);

                // apply encoding and transliteration, if needed
                $enc_trans_value = $this->encodeExportCSVField($export_value);

                // make sure transliteration or encoding did not break the string
                if (empty($enc_trans_value) && !empty($export_value)) {
                    // fallback to original and raw value
                    $enc_trans_value = $export_value;
                }

                // push row value
                $csvrow[] = $enc_trans_value;
            }

            // push the whole row
            $csvlines[] = $csvrow;
        }

        // return the list of lines
        return $csvlines;
    }

    /**
     * Checks if a custom resource file pointer to export the file has been set.
     * 
     * @return  bool
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    public function hasExportHandler()
    {
        return is_resource($this->fp_export);
    }

    /**
     * Returns the resource file pointer on which the CSV lines will be exported.
     * 
     * @return  resource
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    public function getExportCSVHandler()
    {
        if (is_null($this->fp_export)) {
            // set the default resource file pointer (output)
            $this->setExportCSVHandler();
        }

        return $this->fp_export;
    }

    /**
     * Sets the resource file pointer on which the CSV lines will be exported.
     * Useful in case the export should be made on a file rather than on output.
     * 
     * @param   string|resource     $filename   file path, identifier or resource.
     * @param   string              $mode       the mode for opening the file pointer.
     * 
     * @return  self
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    public function setExportCSVHandler($filename = 'php://output', $mode = 'w')
    {
        // set resource file pointer
        if (is_resource($filename)) {
            $this->fp_export = $filename;
        } else {
            $this->fp_export = fopen($filename, $mode);
        }

        return $this;
    }

    /**
     * Sets the current type of CSV export format.
     * 
     * @param   string  $format     either "csv" or "excel".
     * 
     * @return  self
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    public function setExportCSVFormat($format)
    {
        $export_format = 'csv';
        if (!strcasecmp($format, 'excel')) {
            $export_format = 'excel';
        }

        // set format type
        $this->csv_export_format = $export_format;

        return $this;
    }

    /**
     * Sends to output the necessary headers to download the export file.
     * 
     * @param   array   $headers    optional additional or actual headers.
     * @param   bool    $replace    if true, only the provided headers will be used.
     * 
     * @return  void
     */
    public function outputHeaders(array $headers = [], $replace = false)
    {
        foreach ($headers as $header) {
            // send custom header
            header($header);
        }

        if ($replace) {
            // do not send any other header
            return;
        }

        // force the download of the CSV file
        if (!strcasecmp($this->csv_export_format, 'excel')) {
            // file compatible with Excel
            header('Content-type: text/csv; charset=UTF-16LE');
        } else {
            // regular CSV
            header('Content-type: text/csv; charset=UTF-8');
        }
        header('Cache-Control: no-store, no-cache');
        header('Content-Disposition: attachment; filename="' . addslashes(basename($this->getExportCSVFileName(), '.csv')) . '.csv"');
    }

    /**
     * Writes the CSV lines to export onto the current resource handler.
     * 
     * @return  bool
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     */
    public function outputCSV(array $csvlines)
    {
        // get resource file pointer
        $fp = $this->getExportCSVHandler();

        if (!$fp) {
            // resource file pointer unavailable
            return false;
        }

        // default CSV delimiter and enclosure
        $separator = ',';
        $enclosure = '"';
        if (!strcasecmp($this->csv_export_format, 'excel')) {
            // for Excel we use the semicolon as separator and double quotes as enclosure
            $separator = ';';
        }

        // send lines to output
        foreach ($csvlines as $csvline) {
            if (is_string($csvline)) {
                // must be the first line instructions for the export format
                fputs($fp, $csvline);

                // go to the next line
                continue;
            }

            // put the array of values as a new CSV line
            fputcsv($fp, $csvline, $separator, $enclosure, $escape = '');
        }

        // close the file pointer
        fclose($fp);

        return true;
    }

    /**
     * Loads a specific report class and returns its instance.
     * Should be called for instantiating any report sub-class.
     * 
     * @param   string  $report     the report file name (i.e. "revenue").
     * 
     * @return  mixed   false or requested report object.
     */
    public static function getInstanceOf($report)
    {
        if (empty($report) || !is_string($report)) {
            return false;
        }

        if (substr($report, -4) != '.php') {
            $report .= '.php';
        }

        $report_path = dirname(__FILE__) . DIRECTORY_SEPARATOR . $report;
        $classname = 'VikBookingReport' . str_replace(' ', '', ucwords(str_replace('.php', '', str_replace('_', ' ', $report))));

        if (!is_file($report_path)) {
            /**
             * Trigger event to let other plugins register additional drivers.
             *
             * @since   1.16.0 (J) - 1.6.0 (WP)
             */
            $list = VBOFactory::getPlatform()->getDispatcher()->filter('onLoadPmsReports');
            foreach ($list as $chunk) {
                if (!is_array($chunk) || !$chunk) {
                    continue;
                }
                foreach ($chunk as $thirdp_report) {
                    if (basename($thirdp_report) == $report) {
                        // driver found
                        $report_path = $thirdp_report;
                        break;
                    }
                }
            }
        }

        if (!is_file($report_path)) {
            // report driver file not found
            return false;
        }

        // load report
        require_once $report_path;

        if (!class_exists($classname)) {
            // report class does not exist
            return false;
        }

        // instantiate the report object
        $reportInstance = new $classname;

        // ensure the report is compatible with the current environment
        if ($reportInstance->preflight() === false) {
            // the report is not compatible
            return false;
        }

        // return the instance of the report object found
        return $reportInstance;
    }

    /**
     * Injects request variables for the report like if some filters were set.
     * 
     * @param   array   $vars   associative list of request vars to inject.
     *
     * @return  void
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP) static-context used to construct the report object later.
     */
    public static function setRequestVars(array $vars)
    {
        foreach ($vars as $key => $value) {
            /**
             * For more safety across different platforms and versions (J3/J4 or WP)
             * we inject values in the super global array as well as in the input object.
             */
            VikRequest::setVar($key, $value, 'request');
            VikRequest::setVar($key, $value);
        }
    }

    /**
     * Proxy for object-context to inject request variables for the report.
     * 
     * @param   array   $params     associative list of request vars to inject.
     *
     * @return  self
     */
    public function injectParams($params)
    {
        if (is_array($params) && $params) {
            self::setRequestVars($params);
        }

        return $this;
    }

    /**
     * Loads Charts CSS/JS assets.
     *
     * @return  self
     */
    public function loadChartsAssets()
    {
        $document = JFactory::getDocument();
        $document->addStyleSheet(VBO_ADMIN_URI . 'resources/Chart.min.css', ['version' => VIKBOOKING_SOFTWARE_VERSION]);
        $document->addScript(VBO_ADMIN_URI . 'resources/Chart.min.js', ['version' => VIKBOOKING_SOFTWARE_VERSION]);

        return $this;
    }

    /**
     * Loads the jQuery UI Datepicker.
     * Method used only by sub-classes.
     *
     * @return  self
     */
    protected function loadDatePicker()
    {
        $vbo_app = VikBooking::getVboApplication();
        $vbo_app->loadDatePicker();

        return $this;
    }

    /**
     * Parses a report row into an associative list of key-value pairs.
     * 
     * @param   array   $row        The report row to parse.
     * @param   bool    $format     True to format values with callbacks.
     * 
     * @return  array               Associative list of row keys and values.
     * 
     * @since   1.18.4 (J) - 1.8.4 (WP)
     */
    protected function getAssocRowFields(array $row, bool $format = false)
    {
        $row_data = [];

        foreach ($row as $field) {
            $field_val = $field['value'];
            if (!isset($field['callback_export']) && isset($field['callback'])) {
                $field['callback_export'] = $field['callback'];
            }
            if ($format === true && !($field['no_export_callback'] ?? 0) && is_callable($field['callback_export'] ?? null)) {
                $field_val = $field['callback_export']($field_val);
            }
            $row_data[$field['key']] = $field_val;
        }

        return $row_data;
    }

    /**
     * Gets the CSS classes for each report row.
     * 
     * @return  array
     * 
     * @since   1.18.6 (J) - 1.8.6 (WP)
     */
    public function getReportRowClasses()
    {
        return $this->reportRowClasses;
    }

    /**
     * Sets the CSS classes for each report row.
     * 
     * @param   array   $rowClasses     List of row class values.
     * 
     * @return  void
     * 
     * @since   1.18.6 (J) - 1.8.6 (WP)
     */
    protected function setReportRowClasses(array $rowClasses)
    {
        $this->reportRowClasses = $rowClasses;

        return;
    }

    /**
     * Applies the proper encoding to the field being added to
     * the CSV lines for export, depending on CSV or Excel.
     * 
     * @param   string  $field  the value being added to the export line.
     * 
     * @return  string          either the original or the properly encoded field.
     * 
     * @since   1.16.1 (J) - 1.6.1 (WP)
     * @since   1.16.8 (J) - 1.6.8 (WP) introduced hook to allow to manipulate encoding.
     */
    protected function encodeExportCSVField($field)
    {
        /**
         * Trigger event to allow third-party plugins to manipulate encoding,
         * in case certain dependencies are not available, such as "mb" support,
         * PECL intl for "transliterator_transliterate" or "iconv" to ASCII.
         * 
         * @since   1.16.8 (J) - 1.6.8 (WP)
         */
        $apply_encoding = VBOFactory::getPlatform()->getDispatcher()->filter('onBeforeEncodingCSVField', [&$field, $this->csv_export_format]);

        if (in_array(false, $apply_encoding, true)) {
            // the hook ordered to not proceed with applying any encoding
            return $field;
        }

        if (!is_string($field) || !strcasecmp($this->csv_export_format, 'csv')) {
            // apply no encoding in case of regular CSV or if non-string data type
            return $field;
        }

        // process the Excel-like string field
        if (preg_match('/[\\x80-\\xff]/', $field)) {
            // UTF-8 encoding detected
            if (function_exists('transliterator_transliterate')) {
                // if Transliterator is available (PECL intl >= 2.0.0), transliterate to ASCII
                $field = transliterator_transliterate('Any-Latin; Latin-ASCII;', $field);
            }

            // attempt to convert UTF-8 to ASCII to support currencies
            $field = iconv("UTF-8", "ASCII//TRANSLIT//IGNORE", $field);
        }

        if (!function_exists('mb_convert_encoding')) {
            // abort to prevent server errors
            return $field;
        }

        // convert encoding to UTF-16LE (low-endian with BOM)
        return mb_convert_encoding($field, 'UTF-16LE', ['ASCII', 'UTF-8', 'ISO-8859-1']);
    }

    /**
     * Used to apply transliteration over UTF-8 characters for having only latins chars.
     * 
     * @param   string  $value  The original string value.
     * 
     * @return  string          The transliterated string or the original string.
     * 
     * @since   1.18.0 (J) - 1.8.0 (WP)
     */
    protected function transliterateToAscii(string $value)
    {
        if (!preg_match('/[\\x80-\\xff]/', $value)) {
            // no UTF-8 encoding (special character) detected
            return $value;
        }

        // make a safe copy of the original string
        $copy_value = $value;

        if (function_exists('transliterator_transliterate')) {
            // if Transliterator is available (PECL intl >= 2.0.0), transliterate to ASCII
            $copy_value = transliterator_transliterate('Any-Latin; Latin-ASCII;', $copy_value);
        }

        if (function_exists('iconv')) {
            // attempt to convert UTF-8 to ASCII
            $copy_value = iconv("UTF-8", "ASCII//TRANSLIT//IGNORE", $copy_value);
        }

        if (empty($copy_value)) {
            // revert to the original string value
            $copy_value = $value;
        }

        return $copy_value;
    }

    /**
     * Loads all the rooms in VBO and returns the array.
     *
     * @return  array
     */
    protected function getRooms()
    {
        $q = "SELECT * FROM `#__vikbooking_rooms` ORDER BY `name` ASC;";
        $this->dbo->setQuery($q);
        $rooms = $this->dbo->loadAssocList();

        return $rooms;
    }

    /**
     * Loads all the rate plans in VBO and returns the array.
     *
     * @return  array
     * 
     * @since   1.15.0 (J) - 1.5.0 (WP)
     */
    protected function getRatePlans()
    {
        $q = "SELECT * FROM `#__vikbooking_prices` ORDER BY `name` ASC;";
        $this->dbo->setQuery($q);
        $rplans = $this->dbo->loadAssocList();

        return VikBooking::sortRatePlans($rplans);
    }

    /**
     * Returns the number of total units for all rooms, or for a specific room.
     * By default, the rooms unpublished are skipped, and all rooms are used.
     * 
     * @param   [mixed] $idroom     int or array.
     * @param   [int]   $published  true or false.
     *
     * @return  int
     */
    protected function countRooms($idroom = 0, $published = 1)
    {
        $clauses = [];
        if (is_int($idroom) && $idroom > 0) {
            $clauses[] = "`id`=".(int)$idroom;
        } elseif (is_array($idroom) && $idroom) {
            $clauses[] = "`id` IN (" . implode(', ', $idroom) . ")";
        }
        if ($published) {
            $clauses[] = "`avail`=1";
        }

        $q = "SELECT SUM(`units`) FROM `#__vikbooking_rooms`".($clauses ? " WHERE ".implode(' AND ', $clauses) : "").";";
        $this->dbo->setQuery($q);
        $totrooms = (int)$this->dbo->loadResult();

        return $totrooms;
    }

    /**
     * Concatenates the JavaScript rules.
     * Method used only by sub-classes.
     *
     * @param   string      $str
     *
     * @return  self
     */
    protected function setScript($str)
    {
        $this->reportScript .= $str."\n";

        return $this;
    }

    /**
     * Gets the current script string.
     *
     * @return  string
     */
    public function getScript()
    {
        return rtrim($this->reportScript, "\n");
    }

    /**
     * Returns the date format in VBO for date, jQuery UI, Joomla/WordPress.
     * The visibility of this method should be public for anyone who needs it.
     *
     * @param   string      $type
     *
     * @return  string
     */
    public function getDateFormat($type = 'date')
    {
        $nowdf = VikBooking::getDateFormat();
        if ($nowdf == "%d/%m/%Y") {
            $df = 'd/m/Y';
            $juidf = 'dd/mm/yy';
        } elseif ($nowdf == "%m/%d/%Y") {
            $df = 'm/d/Y';
            $juidf = 'mm/dd/yy';
        } else {
            $df = 'Y/m/d';
            $juidf = 'yy/mm/dd';
        }

        switch ($type) {
            case 'jui':
                return $juidf;
            case 'joomla':
            case 'wordpress':
                return $nowdf;
            default:
                return $df;
        }
    }

    /**
     * Returns the translated weekday.
     * Uses the back-end language definitions.
     *
     * @param   int     $wday
     * @param   string  $type   use 'long' for the full name of the week, short for the 3-char version
     *
     * @return  string
     */
    protected function getWdayString($wday, $type = 'long')
    {
        $wdays_map_long = [
            JText::translate('VBWEEKDAYZERO'),
            JText::translate('VBWEEKDAYONE'),
            JText::translate('VBWEEKDAYTWO'),
            JText::translate('VBWEEKDAYTHREE'),
            JText::translate('VBWEEKDAYFOUR'),
            JText::translate('VBWEEKDAYFIVE'),
            JText::translate('VBWEEKDAYSIX')
        ];

        $wdays_map_short = [
            JText::translate('VBSUN'),
            JText::translate('VBMON'),
            JText::translate('VBTUE'),
            JText::translate('VBWED'),
            JText::translate('VBTHU'),
            JText::translate('VBFRI'),
            JText::translate('VBSAT')
        ];

        if ($type != 'long') {
            return isset($wdays_map_short[(int)$wday]) ? $wdays_map_short[(int)$wday] : '';
        }

        return isset($wdays_map_long[(int)$wday]) ? $wdays_map_long[(int)$wday] : '';
    }

    /**
     * Sets the columns for this report.
     *
     * @param   array   $arr
     *
     * @return  self
     */
    public function setReportCols($arr)
    {
        $this->cols = $arr;

        return $this;
    }

    /**
     * Returns the columns for this report.
     * Should be called after getReportData()
     * or the returned array will be empty.
     *
     * @return  array
     */
    public function getReportCols()
    {
        return $this->cols;
    }

    /**
     * Sorts the rows of the report by key.
     *
     * @param   string      $krsort     the key attribute of the array pairs
     * @param   string      $krorder    ascending (ASC) or descending (DESC)
     *
     * @return  void
     */
    protected function sortRows($krsort, $krorder)
    {
        if (empty($krsort) || !$this->rows) {
            return;
        }

        $map = [];
        foreach ($this->rows as $k => $row) {
            foreach ($row as $kk => $v) {
                if (isset($v['key']) && $v['key'] == $krsort) {
                    $map[$k] = $v['value'];
                }
            }
        }
        if (!$map) {
            return;
        }

        if ($krorder == 'ASC') {
            asort($map);
        } else {
            arsort($map);
        }

        $sorted = [];
        foreach ($map as $k => $v) {
            $sorted[$k] = $this->rows[$k];
        }

        $this->rows = $sorted;
    }

    /**
     * Sets the rows for this report.
     *
     * @param   array   $arr
     *
     * @return  self
     */
    public function setReportRows($arr)
    {
        $this->rows = $arr;

        return $this;
    }

    /**
     * Returns the rows for this report.
     * Should be called after getReportData()
     * or the returned array will be empty.
     *
     * @return  array
     */
    public function getReportRows()
    {
        return $this->rows;
    }

    /**
     * This method returns one or more rows (given the depth) generated by
     * the current report invoked. It is useful to clean up the callbacks
     * of the various cell-rows, to obtain a parsable result.
     * Can be called as first method, by skipping also getReportData(). 
     * 
     * @param   ?int     $depth  how many records to obtain, null for all.
     *
     * @return  array    the queried report value in the given depth.
     * 
     * @uses    getReportData()
     */
    public function getReportValues(?int $depth = null)
    {
        if (!$this->rows && !$this->getReportData()) {
            return [];
        }

        $report_values = [];

        foreach ($this->rows as $rk => $row) {
            $report_values[$rk] = [];
            foreach ($row as $col => $coldata) {
                $display_value = $coldata['value'];
                if (isset($coldata['callback']) && is_callable($coldata['callback'])) {
                    // launch callback
                    $display_value = $coldata['callback']($coldata['value']);
                }
                // push column value
                $report_values[$rk][$coldata['key']] = [
                    'value'         => $coldata['value'],
                    'display_value' => $display_value,
                ];
                /**
                 * We also pass along any reserved key for this row-data.
                 * 
                 * @since   1.15.0 (J) - 1.5.0 (WP)
                 */
                foreach ($coldata as $res_key => $data_val) {
                    if (substr($res_key, 0, 1) == '_') {
                        // push this reserved key
                        $report_values[$rk][$coldata['key']][$res_key] = $data_val;
                    }
                }
            }
        }

        if (!$report_values) {
            return [];
        }

        if ($depth === 1) {
            // get an associative array with the first row calculated
            return $report_values[0];
        }

        if (is_int($depth) && $depth > 0 && count($report_values) >= $depth) {
            // get the requested portion of the array
            return array_slice($report_values, 0, $depth);
        }

        return $report_values;
    }

    /**
     * Maps the columns labels to an associative array to be used for the values.
     * 
     * @return  array   associative list of column keys and related values.
     */
    public function getColumnsValues()
    {
        if (!$this->cols) {
            return [];
        }

        $col_values = [];

        foreach ($this->cols as $col) {
            if (!isset($col['key'])) {
                continue;
            }
            $col_values[$col['key']] = $col;
            unset($col_values[$col['key']]['key']);
        }

        return $col_values;
    }

    /**
     * Gets a property defined by the report. Useful to get custom
     * properties set up by a specific report maybe for the Chart.
     * 
     * @param   string  $property   the name of the property needed.
     * @param   mixed   $def        default value to return.
     * 
     * @return  mixed   false on failure, property requested otherwise.
     */
    public function getProperty($property, $def = false)
    {
        if (isset($this->{$property})) {
            return $this->{$property};
        }

        return $def;
    }

    /**
     * Counts the number of days of difference between two timestamps.
     * 
     * @param   int     $to_ts      the target end date timestamp.
     * @param   int     $from_ts    the starting date timestamp.
     * 
     * @return  int     the days of difference between from and to timestamps.
     */
    public function countDaysTo($to_ts, $from_ts = 0)
    {
        if (empty($from_ts)) {
            $from_ts = time();
        }

        // whether DateTime can be used
        $usedt = false;

        if (class_exists('DateTime')) {
            $from_date = new DateTime(date('Y-m-d', $from_ts));
            if (method_exists($from_date, 'diff')) {
                $usedt = true;
            }
        }

        if ($usedt) {
            $to_date = new DateTime(date('Y-m-d', $to_ts));
            $daysdiff = (int)$from_date->diff($to_date)->format('%a');
            if ($to_ts < $from_ts) {
                // we need a negative integer number
                $daysdiff = $daysdiff - ($daysdiff * 2);
            }
            return $daysdiff;
        }

        return (int)round(($to_ts - $from_ts) / 86400);
    }

    /**
     * Counts the average difference between two integers.
     * 
     * @param   int     $in_days_from   days to the lowest timestamp.
     * @param   int     $in_days_to     days to the highest timestamp.
     * 
     * @return  int     the average number between the two values.
     */
    public function countAverageDays($in_days_from, $in_days_to)
    {
        return (int)floor(($in_days_from + $in_days_to) / 2);
    }

    /**
     * Sets the footer row (the totals) for this report.
     *
     * @param   array   $arr
     *
     * @return  self
     */
    protected function setReportFooterRow($arr)
    {
        $this->footerRow = $arr;

        return $this;
    }

    /**
     * Returns the footer row for this report.
     * Should be called after getReportData()
     * or the returned array will be empty.
     *
     * @return  array
     */
    public function getReportFooterRow()
    {
        return $this->footerRow;
    }

    /**
     * Sub-classes can extend this method to define the
     * the canvas HTML tag for rendenring the Chart.
     * Any necessary script shall be set within this method.
     * Data can be passed as a mixed value through the argument.
     * This is the first method to be called when working with the Chart.
     * 
     * @param   ?array  $data   any necessary value to render the Chart.
     *
     * @return  string  the HTML of the canvas element.
     */
    public function getChart(?array $data = null)
    {
        return '';
    }

    /**
     * Sub-classes can extend this method to define the
     * the title of the Chart to be rendered.
     *
     * @return  string  the title of the Chart.
     */
    public function getChartTitle()
    {
        return '';
    }

    /**
     * Sub-classes can extend this method to define
     * the meta data for the Chart containing stats.
     * An array for each meta-data should be returned.
     * 
     * @param   mixed   $position   string for the meta-data position
     *                              in the Chart (top, right, bottom).
     * @param   mixed   $data       some arguments to be passed.
     *
     * @return  array
     */
    public function getChartMetaData($position = null, $data = null)
    {
        return [];
    }

    /**
     * Sets an array of custom options for this report. Useful to inject
     * params before getting the report data and changing the behavior.
     *
     * @param   array   $options    The associative options to set.
     *
     * @return  self
     * 
     * @since   1.15.0 (J) - 1.5.0 (WP)
     */
    public function setReportOptions(array $options = [])
    {
        $this->options = $options;

        return $this;
    }

    /**
     * Returns the custom options for the report. Useful to
     * behave differently depending on who calls the report.
     * 
     * @param   bool    $registry           True to wrap options into a registry.
     * @param   bool    $profileListings    True to merge report profile listings setting.
     *
     * @return  JObject|array
     * 
     * @since   1.15.0 (J) - 1.5.0 (WP)
     * @since   1.18.7 (J) - 1.8.7 (WP) added argument $profileListings.
     */
    public function getReportOptions(bool $registry = true, bool $profileListings = true)
    {
        if ($profileListings && $this->allowsProfileListings()) {
            // load report settings
            $reportSettings = $this->loadSettings();
            if ($reportSettings['_listings'] ?? null) {
                // options should force profile listings
                $this->options['listings'] = (array) $reportSettings['_listings'];
            }
        }

        if ($registry) {
            return new JObject($this->options);
        }

        return $this->options;
    }

    /**
     * Defines an associative list of action data.
     *
     * @param   array   $data   Associative list of action data.
     *
     * @return  self
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function setActionData(array $data)
    {
        $this->actionData = $data;

        return $this;
    }

    /**
     * Returns the action data, either raw or as a registry.
     * 
     * @param   bool    $registry   True to get a JObject instance.
     *
     * @return  array|JObject
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function getActionData($registry = true)
    {
        if ($registry) {
            return new JObject($this->actionData);
        }

        return $this->actionData;
    }

    /**
     * Sets the global scope for the report.
     * 
     * @param   string  $scope  The scope to set.
     * 
     * @return  self
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function setScope($scope)
    {
        $this->scope = $scope;

        return $this;
    }

    /**
     * Returns the global scope of the invoked report.
     * 
     * @return  ?string
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function getScope()
    {
        return $this->scope;
    }

    /**
     * Returns the path to the PMS media data directory.
     * 
     * @return  string
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function getDataMediaPath()
    {
        return implode(DIRECTORY_SEPARATOR, [VBO_ADMIN_PATH, 'resources', 'pmsdata']);
    }

    /**
     * Returns the URL to the PMS media data directory.
     * 
     * @return  string
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function getDataMediaUrl()
    {
        return VBO_ADMIN_URI . 'resources/pmsdata/';
    }

    /**
     * Returns the optional report custom setting fields at profile level.
     * This method allows to load the report setting fields by injecting
     * profile-level (protected) preferences, such as listings filtered.
     * 
     * @return  array
     * 
     * @since   1.18.7 (J) - 1.8.7 (WP)
     */
    public function getProfileSettingFields()
    {
        // load all report setting fields
        $reportSettingFields = $this->getSettingFields();

        if ($reportSettingFields && $this->allowsProfileListings()) {
            // set protected listing setting field
            $reportSettingFields['_listings'] = [
                'type'     => 'listings',
                'label'    => JText::translate('VBO_LISTINGS'),
                'help'     => sprintf('(%s)', JText::translate('VBOFILTEISROPTIONAL')),
                'multiple' => true,
                'asset_options' => [
                    'placeholder' => JText::translate('VBO_LISTINGS'),
                    'allowClear'  => true,
                ],
            ];
        }

        // return the full list of setting fields
        return $reportSettingFields;
    }

    /**
     * Returns the optional report custom setting fields.
     * 
     * @return  array
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function getSettingFields()
    {
        return [];
    }

    /**
     * Tells whether the current report allows to save
     * the settings across multiple profile identifiers.
     * 
     * @return  bool
     * 
     * @since   1.17.7 (J) - 1.7.7 (WP)
     */
    public function allowsProfileSettings()
    {
        // report profile settings disabled by default
        return false;
    }

    /**
     * Tells whether the current report allows to save
     * the listings across multiple profile identifiers.
     * 
     * @return  bool
     * 
     * @since   1.18.7 (J) - 1.8.7 (WP)
     */
    public function allowsProfileListings()
    {
        // report profile listings enabled as long as multiple profiles are enabled
        return $this->allowsProfileSettings();
    }

    /**
     * Returns the report currently active profile identifier, if any.
     * 
     * @return  string
     * 
     * @since   1.17.7 (J) - 1.7.7 (WP)
     */
    public function getActiveProfile()
    {
        $profile = (string) VBOFactory::getConfig()->getString('report_active_profile_' . $this->getFileName(), '');

        return $profile;
    }

    /**
     * Sets the report currently active profile identifier.
     * 
     * @param   string  $profile_id     The profile identifier to set.
     * 
     * @return  void
     * 
     * @since   1.17.7 (J) - 1.7.7 (WP)
     */
    public function setActiveProfile(string $profile_id)
    {
        VBOFactory::getConfig()->set('report_active_profile_' . $this->getFileName(), $profile_id);
    }

    /**
     * Returns an associative list for the report setting profiles available.
     * 
     * @return  array
     * 
     * @since   1.17.7 (J) - 1.7.7 (WP)
     */
    public function getSettingProfiles()
    {
        return (array) VBOFactory::getConfig()->getArray('report_profile_list_' . $this->getFileName(), []);
    }

    /**
     * Sets a new report setting profile identifier.
     * 
     * @param   string  $profile_name   The profile name.
     * 
     * @return  array   List of profile identifier and name.
     * 
     * @since   1.17.7 (J) - 1.7.7 (WP)
     */
    public function setSettingProfile(string $profile_name)
    {
        // get all report profiles
        $profiles = $this->getSettingProfiles();

        // build profile identifier
        $profile_id = preg_replace('/[^A-Z0-9]/i', '', strtolower($profile_name));
        $profile_id = $profile_id ?: uniqid();

        // set report profile
        $profiles[$profile_id] = $profile_name;
        VBOFactory::getConfig()->set('report_profile_list_' . $this->getFileName(), $profiles);

        return [$profile_id, $profile_name];
    }

    /**
     * Clears all profile settings and related data.
     * 
     * @return  void
     * 
     * @since   1.17.7 (J) - 1.7.7 (WP)
     */
    public function clearProfiles()
    {
        // unset active profile
        VBOFactory::getConfig()->set('report_active_profile_' . $this->getFileName(), '');

        // unset profiles list
        VBOFactory::getConfig()->set('report_profile_list_' . $this->getFileName(), []);

        // clear profile settings
        VBOFactory::getConfig()->set('report_profile_settings_' . $this->getFileName(), []);
    }

    /**
     * Returns the current report custom settings, optionally loaded from a
     * given profile identifier in case the report supports multiple settings.
     * 
     * @param   string  $profile    Optional settings profile identifier.
     * 
     * @return  array
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function loadSettings(string $profile = '')
    {
        // access report current settings
        $current_settings = (array) VBOFactory::getConfig()->getArray('report_settings_' . $this->getFileName(), []);

        // default settings
        $default_settings = [];

        if (!$profile && $this->allowsProfileSettings()) {
            // load current profile settings
            $profile = $this->getActiveProfile();
            $default_settings = $current_settings;
        }

        if ($profile) {
            // check if the requested profile settings are available
            $profile_settings = (array) VBOFactory::getConfig()->getArray('report_profile_settings_' . $this->getFileName(), []);

            // overwrite report current settings
            $current_settings = $profile_settings[$profile] ?? $default_settings;
        }

        return $current_settings;
    }

    /**
     * Saves the report custom settings defined.
     * The visibility should be public.
     * 
     * @param   array    $data     The associative list of settings to save.
     * @param   bool     $merge    If true, the previous settings will be merged.
     * @param   string   $profile  Optional settings profile identifier.
     * 
     * @return  void
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     * @since   1.17.7 (J) - 1.7.7 (WP) added 3rd argument $profile and related support.
     */
    public function saveSettings(array $data, $merge = true, string $profile = '')
    {
        if ($merge) {
            // build report global settings
            $data = array_merge($this->loadSettings($profile), $data);
        }

        // save report global settings
        VBOFactory::getConfig()->set('report_settings_' . $this->getFileName(), $data);

        if ($profile) {
            // get report current profile settings
            $profile_settings = (array) VBOFactory::getConfig()->getArray('report_profile_settings_' . $this->getFileName(), []);

            // set new profile settings
            $profile_settings[$profile] = $data;

            // save report profile settings
            VBOFactory::getConfig()->set('report_profile_settings_' . $this->getFileName(), $profile_settings);
        }
    }

    /**
     * Returns a numeric list of scoped extra actions.
     * 
     * @param   string  $scope      Optional scope identifier (cron, web, etc..).
     * @param   bool    $visible    If true, the hidden actions will not be returned.
     * 
     * @return  array
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function getScopedActions($scope = null, $visible = true)
    {
        return [];
    }

    /**
     * Executes a custom scoped action within the report.
     * 
     * @param   string  $action The custom action to invoke.
     * @param   string  $scope  Optional scope identifier (cron, web, etc..).
     * @param   array   $data   Optional data for the action to execute.
     * 
     * @return  mixed
     * 
     * @throws  Exception
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function executeAction($action, $scope = null, array $data = [])
    {
        if (is_null($scope)) {
            $scope = $this->getScope();
        }

        if (!$data) {
            $data = $this->getActionData($registry = false);
        }

        $callable = [$this, $action];

        if (!is_callable($callable)) {
            throw new Exception('Could not call the requested report action.', 500);
        }

        return call_user_func_array($callable, [$scope, $data]);
    }

    /**
     * Proxy for executing a custom scoped action and returning a property.
     * 
     * @param   string  $action The custom action to invoke.
     * @param   string  $return The action result property to return.
     * @param   string  $scope  Optional scope identifier (cron, web, etc..).
     * @param   array   $data   Optional data for the action to execute.
     * 
     * @return  mixed
     * 
     * @throws  Exception
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function _callActionReturn($action, $return, $scope = null, array $data = [])
    {
        if (is_null($scope)) {
            $scope = $this->getScope();
        }

        if (!$data) {
            $data = $this->getActionData($registry = false);
        }

        $result = (array) $this->executeAction($action, $scope, $data);

        if (!isset($result[$return])) {
            throw new Exception('Could not return the requested action result property.', 500);
        }

        return $result[$return];
    }

    /**
     * Defines a new resource file generated through a custom action.
     * 
     * @param   array   $data   Resource file data to bind.
     * 
     * @return  self
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    protected function defineResourceFile(array $data)
    {
        $element = new VBOReportResourceElement($data);

        if ($element->getUrl()) {
            // ensure the resource is available
            $this->resourceFiles[] = $element;
        }

        return $this;
    }

    /**
     * Returns the resource files generated through custom actions.
     * 
     * @return  array
     * 
     * @since   1.17.1 (J) - 1.7.1 (WP)
     */
    public function getResourceFiles()
    {
        return $this->resourceFiles;
    }

    /**
     * Sets warning messages by concatenating the existing ones.
     * Method used only by sub-classes.
     *
     * @param   string      $str
     *
     * @return  self
     */
    protected function setWarning($str)
    {
        $this->warning .= $str."\n";

        return $this;
    }

    /**
     * Gets the current warning string.
     *
     * @return  string
     */
    public function getWarning()
    {
        return rtrim($this->warning, "\n");
    }

    /**
     * Resets any errors previously set.
     *
     * @return  self
     * 
     * @since   1.18.3 (J) - 1.8.3 (WP)
     */
    protected function resetErrors()
    {
        $this->error = '';

        return $this;
    }

    /**
     * Sets errors by concatenating the existing ones.
     * Method used only by sub-classes.
     *
     * @param   string      $str
     *
     * @return  self
     */
    protected function setError($str)
    {
        $this->error .= $str."\n";

        return $this;
    }

    /**
     * Gets the current error string.
     *
     * @return  string
     */
    public function getError()
    {
        return rtrim($this->error, "\n");
    }
}
