<?php

/**
 * Matomo - free/libre analytics platform
 *
 * @link    https://matomo.org
 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 */

namespace Piwik\DataTable;

use Exception;
use Piwik\Columns\Dimension;
use Piwik\Common;
use Piwik\DataTable;
use Piwik\Metrics;
use Piwik\Piwik;
use Piwik\BaseFactory;

/**
 * A DataTable Renderer can produce an output given a DataTable object.
 * All new Renderers must be copied in DataTable/Renderer and added to the factory() method.
 * To use a renderer, simply do:
 *  $render = new Xml();
 *  $render->setTable($dataTable);
 *  echo $render;
 */
abstract class Renderer extends BaseFactory
{
    protected $table;

    /**
     * @var Exception
     */
    protected $exception;
    protected $renderSubTables = false;

    /** @var bool */
    protected $hideIdSubDatatable = false;
    /** @var bool */
    protected $hideMetadata = false;

    /**
     * Whether to translate column names (i.e. metric names) or not
     * @var bool
     */
    public $translateColumnNames = false;

    /**
     * Column translations
     * @var null|array
     */
    private $columnTranslations = null;

    /**
     * The API method that has returned the data that should be rendered
     * @var null|string
     */
    public $apiMethod = null;

    /**
     * API metadata for the current report
     * @var array|false|null
     */
    private $apiMetaData = null;

    /**
     * The current idSite
     * @var int|string
     */
    public $idSite = 'all';

    public function __construct()
    {
    }

    /**
     * Sets whether to render subtables or not
     */
    public function setRenderSubTables(bool $enableRenderSubTable): void
    {
        $this->renderSubTables = $enableRenderSubTable;
    }

    public function setHideIdSubDatableFromResponse(bool $hideIdSubDataTable): void
    {
        $this->hideIdSubDatatable = $hideIdSubDataTable;
    }

    public function setHideMetadataFromResponse(bool $hideMetadata): void
    {
        $this->hideMetadata = $hideMetadata;
    }

    /**
     * Returns whether to render subtables or not
     */
    protected function isRenderSubtables(): bool
    {
        return $this->renderSubTables;
    }

    /**
     * Output HTTP Content-Type header
     */
    protected function renderHeader()
    {
        Common::sendHeader('Content-Type: text/plain; charset=utf-8');
    }

    /**
     * Computes the dataTable output and returns the string/binary
     *
     * @return string
     */
    abstract public function render();

    /**
     * @see render()
     */
    public function __toString(): string
    {
        return $this->render();
    }

    /**
     * Set the DataTable to be rendered
     *
     * @param DataTableInterface|array $table table to be rendered
     */
    public function setTable($table)
    {
        if (
            !is_array($table)
            && !($table instanceof DataTableInterface)
        ) {
            throw new Exception("DataTable renderers renderer accepts only DataTable, Simple and Map instances, and arrays.");
        }
        $this->table = $table;
    }

    /**
     * @var array
     */
    protected static $availableRenderers = array('xml',
                                                 'json',
                                                 'csv',
                                                 'tsv',
                                                 'html',
    );

    /**
     * Returns available renderers
     *
     * @return array
     */
    public static function getRenderers()
    {
        return self::$availableRenderers;
    }

    protected static function getClassNameFromClassId($id)
    {
        $className = ucfirst(strtolower($id));
        $className = 'Piwik\DataTable\Renderer\\' . $className;

        return $className;
    }

    protected static function getInvalidClassIdExceptionMessage($id)
    {
        $availableRenderers = implode(', ', self::getRenderers());
        $klassName = self::getClassNameFromClassId($id);

        return Piwik::translate('General_ExceptionInvalidRendererFormat', array($klassName, $availableRenderers));
    }

    /**
     * Format a value to xml
     *
     * @param string|number|bool $value value to format
     * @return int|string
     */
    public static function formatValueXml($value)
    {
        if (
            is_string($value)
            && !is_numeric($value)
        ) {
            $value = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
            // make sure non-UTF-8 chars don't cause htmlspecialchars to choke
            if (function_exists('mb_convert_encoding')) {
                $value = @mb_convert_encoding($value, 'UTF-8', 'UTF-8');
            }
            $value = htmlspecialchars($value, ENT_COMPAT, 'UTF-8');

            $htmlentities = array(
                "&nbsp;", "&iexcl;", "&cent;", "&pound;", "&curren;", "&yen;", "&brvbar;", "&sect;", "&uml;", "&copy;",
                "&ordf;", "&laquo;", "&not;", "&shy;", "&reg;", "&macr;", "&deg;", "&plusmn;", "&sup2;", "&sup3;",
                "&acute;", "&micro;", "&para;", "&middot;", "&cedil;", "&sup1;", "&ordm;", "&raquo;", "&frac14;",
                "&frac12;", "&frac34;", "&iquest;", "&Agrave;", "&Aacute;", "&Acirc;", "&Atilde;", "&Auml;", "&Aring;",
                "&AElig;", "&Ccedil;", "&Egrave;", "&Eacute;", "&Ecirc;", "&Euml;", "&Igrave;", "&Iacute;", "&Icirc;",
                "&Iuml;", "&ETH;", "&Ntilde;", "&Ograve;", "&Oacute;", "&Ocirc;", "&Otilde;", "&Ouml;", "&times;",
                "&Oslash;", "&Ugrave;", "&Uacute;", "&Ucirc;", "&Uuml;", "&Yacute;", "&THORN;", "&szlig;", "&agrave;",
                "&aacute;", "&acirc;", "&atilde;", "&auml;", "&aring;", "&aelig;", "&ccedil;", "&egrave;", "&eacute;",
                "&ecirc;", "&euml;", "&igrave;", "&iacute;", "&icirc;", "&iuml;", "&eth;", "&ntilde;", "&ograve;",
                "&oacute;", "&ocirc;", "&otilde;", "&ouml;", "&divide;", "&oslash;", "&ugrave;", "&uacute;", "&ucirc;",
                "&uuml;", "&yacute;", "&thorn;", "&yuml;", "&euro;",
            );
            $xmlentities  = array(
                "&#162;", "&#163;", "&#164;", "&#165;", "&#166;", "&#167;", "&#168;", "&#169;", "&#170;", "&#171;",
                "&#172;", "&#173;", "&#174;", "&#175;", "&#176;", "&#177;", "&#178;", "&#179;", "&#180;", "&#181;",
                "&#182;", "&#183;", "&#184;", "&#185;", "&#186;", "&#187;", "&#188;", "&#189;", "&#190;", "&#191;",
                "&#192;", "&#193;", "&#194;", "&#195;", "&#196;", "&#197;", "&#198;", "&#199;", "&#200;", "&#201;",
                "&#202;", "&#203;", "&#204;", "&#205;", "&#206;", "&#207;", "&#208;", "&#209;", "&#210;", "&#211;",
                "&#212;", "&#213;", "&#214;", "&#215;", "&#216;", "&#217;", "&#218;", "&#219;", "&#220;", "&#221;",
                "&#222;", "&#223;", "&#224;", "&#225;", "&#226;", "&#227;", "&#228;", "&#229;", "&#230;", "&#231;",
                "&#232;", "&#233;", "&#234;", "&#235;", "&#236;", "&#237;", "&#238;", "&#239;", "&#240;", "&#241;",
                "&#242;", "&#243;", "&#244;", "&#245;", "&#246;", "&#247;", "&#248;", "&#249;", "&#250;", "&#251;",
                "&#252;", "&#253;", "&#254;", "&#255;", "&#8364;",
            );
            $value        = str_replace($htmlentities, $xmlentities, $value);
        } elseif ($value === false) {
            $value = 0;
        }

        return $value;
    }

    /**
     * Translate column names to the current language.
     * Used in subclasses.
     *
     * @param array $names
     * @return array
     */
    protected function translateColumnNames($names)
    {
        if (!$this->apiMethod) {
            return $names;
        }

        // load the translations only once
        // when multiple dates are requested (date=...,...&period=day), the meta data would
        // be loaded lots of times otherwise
        if ($this->columnTranslations === null) {
            $meta = $this->getApiMetaData();
            if ($meta === false) {
                return $names;
            }

            $t = Metrics::getDefaultMetricTranslations();
            foreach (array('metrics', 'processedMetrics', 'metricsGoal', 'processedMetricsGoal') as $index) {
                if (isset($meta[$index]) && is_array($meta[$index])) {
                    $t = array_merge($t, $meta[$index]);
                }
            }

            foreach (Dimension::getAllDimensions() as $dimension) {
                $dimensionId   = str_replace('.', '_', $dimension->getId());
                $dimensionName = $dimension->getName();

                if (!empty($dimensionId) && !empty($dimensionName)) {
                    $t[$dimensionId] = $dimensionName;
                }
            }

            $this->columnTranslations = & $t;
        }

        foreach ($names as &$name) {
            if (isset($this->columnTranslations[$name])) {
                $name = $this->columnTranslations[$name];
            }
        }

        return $names;
    }

    /**
     * @return array|false
     */
    protected function getApiMetaData()
    {
        if ($this->apiMetaData === null) {
            [$apiModule, $apiAction] = explode('.', $this->apiMethod);

            if (!$apiModule || !$apiAction) {
                $this->apiMetaData = false;
            }

            $api = \Piwik\Plugins\API\API::getInstance();
            $meta = $api->getMetadata($this->idSite, $apiModule, $apiAction);
            if (isset($meta[0]) && is_array($meta[0])) {
                $meta = $meta[0];
            }

            $this->apiMetaData = & $meta;
        }

        return $this->apiMetaData;
    }

    /**
     * Translates the given column name
     *
     * @param string $column
     * @return mixed
     */
    protected function translateColumnName($column)
    {
        $columns = array($column);
        $columns = $this->translateColumnNames($columns);
        return $columns[0];
    }

    /**
     * Enables column translating
     *
     * @param bool $bool
     */
    public function setTranslateColumnNames($bool)
    {
        $this->translateColumnNames = $bool;
    }

    /**
     * Sets the api method
     *
     * @param $method
     */
    public function setApiMethod($method)
    {
        $this->apiMethod = $method;
    }

    /**
     * Sets the site id
     *
     * @param int $idSite
     */
    public function setIdSite($idSite)
    {
        $this->idSite = $idSite;
    }

    /**
     * Returns true if an array should be wrapped before rendering. This is used to
     * mimic quirks in the old rendering logic (for backwards compatibility). The
     * specific meaning of 'wrap' is left up to the Renderer. For XML, this means a
     * new <row> node. For JSON, this means wrapping in an array.
     *
     * In the old code, arrays were added to new DataTable instances, and then rendered.
     * This transformation wrapped associative arrays except under certain circumstances,
     * including:
     *  - single element (ie, array('nb_visits' => 0))   (not wrapped for some renderers)
     *  - empty array (ie, array())
     *  - array w/ arrays/DataTable instances as values (ie,
     *            array('name' => 'myreport',
     *                  'reportData' => new DataTable())
     *        OR  array('name' => 'myreport',
     *                  'reportData' => array(...)) )
     *
     * @param array $array
     * @param bool $wrapSingleValues Whether to wrap array('key' => 'value') arrays. Some
     *                               renderers wrap them and some don't.
     * @param bool|null $isAssociativeArray Whether the array is associative or not.
     *                                      If null, it is determined.
     * @return bool
     */
    protected static function shouldWrapArrayBeforeRendering(
        $array,
        $wrapSingleValues = true,
        $isAssociativeArray = null
    ) {
        if (empty($array)) {
            return false;
        }

        if ($isAssociativeArray === null) {
            $isAssociativeArray = Piwik::isAssociativeArray($array);
        }

        $wrap = true;
        if ($isAssociativeArray) {
            // we don't wrap if the array has one element that is a value
            $firstValue = reset($array);
            if (
                !$wrapSingleValues
                && count($array) === 1
                && (!is_array($firstValue)
                    && !is_object($firstValue))
            ) {
                $wrap = false;
            } else {
                foreach ($array as $value) {
                    if (
                        is_array($value)
                        || is_object($value)
                    ) {
                        $wrap = false;
                        break;
                    }
                }
            }
        } else {
            $wrap = false;
        }

        return $wrap;
    }

    /**
     * Produces a flat php array from the DataTable, putting "columns" and "metadata" on the same level.
     *
     * For example, when  a originalRender() would be
     *     array( 'columns' => array( 'col1_name' => value1, 'col2_name' => value2 ),
     *            'metadata' => array( 'metadata1_name' => value_metadata) )
     *
     * a flatRender() is
     *     array( 'col1_name' => value1,
     *            'col2_name' => value2,
     *            'metadata1_name' => value_metadata )
     *
     * @param null|array|DataTable\DataTableInterface $dataTable
     * @return mixed  Php array representing the 'flat' version of the datatable, or a scalar value
     *                when the table is a Simple table with a single column.
     */
    protected function convertDataTableToArray($dataTable = null)
    {
        if (is_null($dataTable)) {
            $dataTable = $this->table;
        }

        if (is_array($dataTable)) {
            $flatArray = $dataTable;
            if (self::shouldWrapArrayBeforeRendering($flatArray)) {
                $flatArray = array($flatArray);
            }
        } elseif ($dataTable instanceof DataTable\Map) {
            $flatArray = array();
            foreach ($dataTable->getDataTables() as $keyName => $table) {
                $flatArray[$keyName] = $this->convertDataTableToArray($table);
            }
        } elseif ($dataTable instanceof Simple) {
            $flatArray = $this->convertSimpleTable($dataTable);

            reset($flatArray);
            $firstKey = key($flatArray);

            // if we return only one numeric value then we print out the result in a simple <result> tag
            // keep it simple!
            if (
                count($flatArray) == 1
                && $firstKey !== DataTable\Row::COMPARISONS_METADATA_NAME
            ) {
                $flatArray = current($flatArray);
            }
        } else {
            // A normal DataTable needs to be handled specifically
            $array = $this->convertTable($dataTable);
            $flatArray = $this->flattenArray($array);
        }

        return $flatArray;
    }

    /**
     * Converts the given data table to an array
     *
     * @param DataTable $table
     * @return array
     */
    protected function convertTable($table)
    {
        $array = [];

        foreach ($table->getRows() as $id => $row) {
            $newRow = [
                'columns'        => $row->getColumns(),
                'metadata'       => $row->getMetadata(),
                'idsubdatatable' => $row->getIdSubDataTable(),
            ];

            if ($id == DataTable::ID_SUMMARY_ROW) {
                $newRow['issummaryrow'] = true;
            }

            if (isset($newRow['metadata'][DataTable\Row::COMPARISONS_METADATA_NAME])) {
                $newRow['metadata'][DataTable\Row::COMPARISONS_METADATA_NAME] = $row->getComparisons();
            }

            $subTable = $row->getSubtable();
            if (
                $this->isRenderSubtables()
                && $subTable
            ) {
                $subTable = $this->convertTable($subTable);
                $newRow['subtable'] = $subTable;
                if (
                    $this->hideIdSubDatatable === false
                    && $this->hideMetadata === false
                    && isset($newRow['metadata']['idsubdatatable_in_db'])
                ) {
                    $newRow['columns']['idsubdatatable'] = $newRow['metadata']['idsubdatatable_in_db'];
                }
                unset($newRow['metadata']['idsubdatatable_in_db']);
            }
            if ($this->hideIdSubDatatable || $this->hideMetadata) {
                unset($newRow['idsubdatatable']);
            }

            $array[] = $newRow;
        }
        return $array;
    }

    /**
     * Converts the simple data table to an array
     *
     * @param Simple $table
     * @return array
     */
    protected function convertSimpleTable($table)
    {
        $array = [];

        $row = $table->getFirstRow();
        if ($row === false) {
            return $array;
        }
        foreach ($row->getColumns() as $columnName => $columnValue) {
            $array[$columnName] = $columnValue;
        }

        $comparisons = $row->getComparisons();
        if (!empty($comparisons)) {
            $array[DataTable\Row::COMPARISONS_METADATA_NAME] = $comparisons;
        }

        return $array;
    }

    /**
     * @param array $array
     * @return array
     */
    protected function flattenArray($array)
    {
        $flatArray = [];
        foreach ($array as $row) {
            if ($this->hideMetadata) {
                $newRow = $row['columns'];
            } else {
                $newRow = $row['columns'] + $row['metadata'];
            }

            if (
                isset($row['idsubdatatable'])
                && $this->hideIdSubDatatable === false
            ) {
                $newRow += ['idsubdatatable' => $row['idsubdatatable']];
            }
            if (isset($row['subtable'])) {
                $newRow += ['subtable' => $this->flattenArray($row['subtable'])];
            }
            $flatArray[] = $newRow;
        }
        return $flatArray;
    }
}
