<?php
/**
 * Piwik - Open source web analytics
 *
 * @link http://piwik.org
 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 *
 */

namespace Piwik\Plugin;

use Piwik\Common;
use Piwik\DataTable;
use Piwik\Date;
use Piwik\Log;
use Piwik\MetricsFormatter;
use Piwik\NoAccessException;
use Piwik\Option;
use Piwik\Period;
use Piwik\Piwik;
use Piwik\Plugins\PrivacyManager\PrivacyManager;
use Piwik\View;
use Piwik\ViewDataTable\Manager as ViewDataTableManager;

/**
 * The base class for report visualizations that output HTML and use JavaScript.
 * 
 * Report visualizations that extend from this class will be displayed like all others in
 * the Piwik UI. The following extra UI controls will be displayed around the visualization
 * itself:
 * 
 * - report documentation,
 * - a footer message (if {@link Piwik\ViewDataTable\Config::$show_footer_message} is set),
 * - a list of links to related reports (if {@link Piwik\ViewDataTable\Config::$related_reports} is set),
 * - a button that allows users to switch visualizations,
 * - a control that allows users to export report data in different formats,
 * - a limit control that allows users to change the amount of rows displayed (if
 *   {@link Piwik\ViewDataTable\Config::$show_limit_control} is true),
 * - and more depending on the visualization.
 * 
 * ### Rendering Process
 * 
 * The following process is used to render reports:
 * 
 * - The report is loaded through Piwik's Reporting API.
 * - The display and request properties that require report data in order to determine a default
 *   value are defaulted. These properties are:
 * 
 *   - {@link Piwik\ViewDataTable\Config::$columns_to_display}
 *   - {@link Piwik\ViewDataTable\RequestConfig::$filter_sort_column}
 *   - {@link Piwik\ViewDataTable\RequestConfig::$filter_sort_order}
 * 
 * - Priority filters are applied to the report (see {@link Piwik\ViewDataTable\Config::$filters}).
 * - The filters that are applied to every report in the Reporting API (called **generic filters**)
 *   are applied. (see {@link Piwik\API\Request})
 * - The report's queued filters are applied.
 * - A {@link Piwik\View} instance is created and rendered.
 * 
 * ### Rendering Hooks
 * 
 * The Visualization class defines several overridable methods that are called at specific
 * points during the rendering process. Derived classes can override these methods change
 * the data that is displayed or set custom properties.
 * 
 * The overridable methods (called **rendering hooks**) are as follows:
 * 
 * - **beforeLoadDataTable**: Called at the start of the rendering process before any data
 *                            is loaded.
 * - **beforeGenericFiltersAreAppliedToLoadedDataTable**: Called after data is loaded and after priority
 *                                                        filters are called, but before other filters. This
 *                                                        method should be used if you need the report's
 *                                                        entire dataset.
 * - **afterGenericFiltersAreAppliedToLoadedDataTable**: Called after generic filters are applied, but before
 *                                                       queued filters are applied.
 * - **afterAllFiltersAreApplied**: Called after data is loaded and all filters are applied.
 * - **beforeRender**: Called immediately before a {@link Piwik\View} is created and rendered.
 * - **isThereDataToDisplay**: Called after a {@link Piwik\View} is created to determine if the report has
 *                             data or not. If not, a message is displayed to the user.
 * 
 * ### The DataTable JavaScript class
 * 
 * In the UI, visualization behavior is provided by logic in the **DataTable** JavaScript class.
 * When creating new visualizations, the **DataTable** JavaScript class (or one of its existing
 * descendants) should be extended.
 * 
 * To learn more read the [Visualizing Report Data](/guides/visualizing-report-data#creating-new-visualizations)
 * guide.
 *
 * ### Examples
 * 
 * **Changing the data that is loaded**
 * 
 *     class MyVisualization extends Visualization
 *     {
 *         // load the previous period's data as well as the requested data. this will change
 *         // $this->dataTable from a DataTable instance to a DataTable\Map instance.
 *         public function beforeLoadDataTable()
 *         {
 *             $date = Common::getRequestVar('date');
 *             list($previousDate, $ignore) = Range::getLastDate($date, $period);
 *
 *             $this->requestConfig->request_parameters_to_modify['date'] = $previousDate . ',' . $date;
 *         }
 * 
 *         // since we load the previous period's data too, we need to override the logic to
 *         // check if there is data or not.
 *         public function isThereDataToDisplay()
 *         {
 *             $tables = $this->dataTable->getDataTables()
 *             $requestedDataTable = end($tables);
 * 
 *             return $requestedDataTable->getRowsCount() != 0;
 *         }
 *     }
 * 
 * **Force properties to be set to certain values**
 * 
 *     class MyVisualization extends Visualization
 *     {
 *         // ensure that some properties are set to certain values before rendering.
 *         // this will overwrite any changes made by plugins that use this visualization.
 *         public function beforeRender()
 *         {
 *             $this->config->max_graph_elements = false;
 *             $this->config->datatable_js_type  = 'MyVisualization';
 *             $this->config->show_flatten_table = false;
 *             $this->config->show_pagination_control = false;
 *             $this->config->show_offset_information = false;
 *         }
 *     }
 */
class Visualization extends ViewDataTable
{
    /**
     * The Twig template file to use when rendering, eg, `"@MyPlugin/_myVisualization.twig"`.
     * 
     * Must be defined by classes that extend Visualization.
     * 
     * @api
     */
    const TEMPLATE_FILE = '';

    private $templateVars = array();
    private $reportLastUpdatedMessage = null;
    private $metadata = null;

    final public function __construct($controllerAction, $apiMethodToRequestDataTable)
    {
        $templateFile = static::TEMPLATE_FILE;

        if (empty($templateFile)) {
            throw new \Exception('You have not defined a constant named TEMPLATE_FILE in your visualization class.');
        }

        parent::__construct($controllerAction, $apiMethodToRequestDataTable);
    }

    protected function buildView()
    {
        $this->overrideSomeConfigPropertiesIfNeeded();

        try {

            $this->beforeLoadDataTable();

            $this->loadDataTableFromAPI(array('disable_generic_filters' => 1));
            $this->postDataTableLoadedFromAPI();

            $requestPropertiesAfterLoadDataTable = $this->requestConfig->getProperties();

            $this->applyFilters();
            $this->afterAllFiltersAreApplied();
            $this->beforeRender();

            $this->logMessageIfRequestPropertiesHaveChanged($requestPropertiesAfterLoadDataTable);

        } catch (NoAccessException $e) {
            throw $e;
        } catch (\Exception $e) {
            Log::warning("Failed to get data from API: " . $e->getMessage() . "\n" . $e->getTraceAsString());

            $loadingError = array('message' => $e->getMessage());
        }

        $view = new View("@CoreHome/_dataTable");

        if (!empty($loadingError)) {
            $view->error = $loadingError;
        }

        $view->assign($this->templateVars);
        $view->visualization         = $this;
        $view->visualizationTemplate = static::TEMPLATE_FILE;
        $view->visualizationCssClass = $this->getDefaultDataTableCssClass();

        if (null === $this->dataTable) {
            $view->dataTable = null;
        } else {
            $view->dataTableHasNoData = !$this->isThereDataToDisplay();
            $view->dataTable          = $this->dataTable;

            // if it's likely that the report data for this data table has been purged,
            // set whether we should display a message to that effect.
            $view->showReportDataWasPurgedMessage = $this->hasReportBeenPurged();
            $view->deleteReportsOlderThan         = Option::get('delete_reports_older_than');
        }

        $view->idSubtable  = $this->requestConfig->idSubtable;
        $view->clientSideParameters = $this->getClientSideParametersToSet();
        $view->clientSideProperties = $this->getClientSidePropertiesToSet();
        $view->properties  = array_merge($this->requestConfig->getProperties(), $this->config->getProperties());
        $view->reportLastUpdatedMessage = $this->reportLastUpdatedMessage;
        $view->footerIcons = $this->config->footer_icons;
        $view->isWidget    = Common::getRequestVar('widget', 0, 'int');

        return $view;
    }

    private function overrideSomeConfigPropertiesIfNeeded()
    {
        if (empty($this->config->footer_icons)) {
            $this->config->footer_icons = ViewDataTableManager::configureFooterIcons($this);
        }

        if (!\Piwik\Plugin\Manager::getInstance()->isPluginActivated('Goals')) {
            $this->config->show_goals = false;
        }
    }

    /**
     * Assigns a template variable making it available in the Twig template specified by
     * {@link TEMPLATE_FILE}.
     *
     * @param array|string $vars One or more variable names to set.
     * @param mixed $value The value to set each variable to.
     * @api
     */
    public function assignTemplateVar($vars, $value = null)
    {
        if (is_string($vars)) {
            $this->templateVars[$vars] = $value;
        } elseif (is_array($vars)) {
            foreach ($vars as $key => $value) {
                $this->templateVars[$key] = $value;
            }
        }
    }

    /**
     * Returns `true` if there is data to display, `false` if otherwise.
     * 
     * Derived classes should override this method if they change the amount of data that is loaded.
     * 
     * @api
     */
    protected function isThereDataToDisplay()
    {
        return !empty($this->dataTable) && 0 < $this->dataTable->getRowsCount();
    }

    /**
     * Hook called after the dataTable has been loaded from the API
     * Can be used to add, delete or modify the data freshly loaded
     *
     * @return bool
     */
    private function postDataTableLoadedFromAPI()
    {
        $columns = $this->dataTable->getColumns();
        $hasNbVisits       = in_array('nb_visits', $columns);
        $hasNbUniqVisitors = in_array('nb_uniq_visitors', $columns);

        // default columns_to_display to label, nb_uniq_visitors/nb_visits if those columns exist in the
        // dataset. otherwise, default to all columns in dataset.
        if (empty($this->config->columns_to_display)) {
            $this->config->setDefaultColumnsToDisplay($columns, $hasNbVisits, $hasNbUniqVisitors);
        }

        if (!empty($this->dataTable)) {
            $this->removeEmptyColumnsFromDisplay();
        }

        if (empty($this->requestConfig->filter_sort_column)) {
            $this->requestConfig->setDefaultSort($this->config->columns_to_display, $hasNbUniqVisitors);
        }

        // deal w/ table metadata
        if ($this->dataTable instanceof DataTable) {
            $this->metadata = $this->dataTable->getAllTableMetadata();

            if (isset($this->metadata[DataTable::ARCHIVED_DATE_METADATA_NAME])) {
                $this->config->report_last_updated_message = $this->makePrettyArchivedOnText();
            }
        }
    }

    private function applyFilters()
    {
        list($priorityFilters, $otherFilters) = $this->config->getFiltersToRun();

        // First, filters that delete rows
        foreach ($priorityFilters as $filter) {
            $this->dataTable->filter($filter[0], $filter[1]);
        }

        $this->beforeGenericFiltersAreAppliedToLoadedDataTable();

        if (!$this->requestConfig->areGenericFiltersDisabled()) {
            $this->applyGenericFilters();
        }

        $this->afterGenericFiltersAreAppliedToLoadedDataTable();

        // queue other filters so they can be applied later if queued filters are disabled
        foreach ($otherFilters as $filter) {
            $this->dataTable->queueFilter($filter[0], $filter[1]);
        }

        // Finally, apply datatable filters that were queued (should be 'presentation' filters that
        // do not affect the number of rows)
        if (!$this->requestConfig->areQueuedFiltersDisabled()) {
            $this->dataTable->applyQueuedFilters();
        }
    }

    private function removeEmptyColumnsFromDisplay()
    {
        if ($this->dataTable instanceof DataTable\Map) {
            $emptyColumns = $this->dataTable->getMetadataIntersectArray(DataTable::EMPTY_COLUMNS_METADATA_NAME);
        } else {
            $emptyColumns = $this->dataTable->getMetadata(DataTable::EMPTY_COLUMNS_METADATA_NAME);
        }

        if (is_array($emptyColumns)) {
            foreach ($emptyColumns as $emptyColumn) {
                $key = array_search($emptyColumn, $this->config->columns_to_display);
                if ($key !== false) {
                    unset($this->config->columns_to_display[$key]);
                }
            }

            $this->config->columns_to_display = array_values($this->config->columns_to_display);
        }
    }

    /**
     * Returns prettified and translated text that describes when a report was last updated.
     *
     * @return string
     */
    private function makePrettyArchivedOnText()
    {
        $dateText = $this->metadata[DataTable::ARCHIVED_DATE_METADATA_NAME];
        $date     = Date::factory($dateText);
        $today    = mktime(0, 0, 0);

        if ($date->getTimestamp() > $today) {

            $elapsedSeconds = time() - $date->getTimestamp();
            $timeAgo        = MetricsFormatter::getPrettyTimeFromSeconds($elapsedSeconds);

            return Piwik::translate('CoreHome_ReportGeneratedXAgo', $timeAgo);
        }

        $prettyDate = $date->getLocalized("%longYear%, %longMonth% %day%") . $date->toString('S');

        return Piwik::translate('CoreHome_ReportGeneratedOn', $prettyDate);
    }

    /**
     * Returns true if it is likely that the data for this report has been purged and if the
     * user should be told about that.
     *
     * In order for this function to return true, the following must also be true:
     * - The data table for this report must either be empty or not have been fetched.
     * - The period of this report is not a multiple period.
     * - The date of this report must be older than the delete_reports_older_than config option.
     * @return bool
     */
    private function hasReportBeenPurged()
    {
        if (!\Piwik\Plugin\Manager::getInstance()->isPluginActivated('PrivacyManager')) {
            return false;
        }

        return PrivacyManager::hasReportBeenPurged($this->dataTable);
    }

    /**
     * Returns array of properties that should be visible to client side JavaScript. The data
     * will be available in the data-props HTML attribute of the .dataTable div.
     *
     * @return array Maps property names w/ property values.
     */
    private function getClientSidePropertiesToSet()
    {
        $result = array();

        foreach ($this->config->clientSideProperties as $name) {
            if (property_exists($this->requestConfig, $name)) {
                $result[$name] = $this->getIntIfValueIsBool($this->requestConfig->$name);
            } else if (property_exists($this->config, $name)) {
                $result[$name] = $this->getIntIfValueIsBool($this->config->$name);
            }
        }

        return $result;
    }

    private function getIntIfValueIsBool($value)
    {
        return is_bool($value) ? (int)$value : $value;
    }

    /**
     * This functions reads the customization values for the DataTable and returns an array (name,value) to be printed in Javascript.
     * This array defines things such as:
     * - name of the module & action to call to request data for this table
     * - optional filters information, eg. filter_limit and filter_offset
     * - etc.
     *
     * The values are loaded:
     * - from the generic filters that are applied by default @see Piwik\API\DataTableGenericFilter::getGenericFiltersInformation()
     * - from the values already available in the GET array
     * - from the values set using methods from this class (eg. setSearchPattern(), setLimit(), etc.)
     *
     * @return array eg. array('show_offset_information' => 0, 'show_...
     */
    protected function getClientSideParametersToSet()
    {
        // build javascript variables to set
        $javascriptVariablesToSet = array();

        foreach ($this->config->custom_parameters as $name => $value) {
            $javascriptVariablesToSet[$name] = $value;
        }

        foreach ($_GET as $name => $value) {
            try {
                $requestValue = Common::getRequestVar($name);
            } catch (\Exception $e) {
                $requestValue = '';
            }
            $javascriptVariablesToSet[$name] = $requestValue;
        }

        foreach ($this->requestConfig->clientSideParameters as $name) {
            if (isset($javascriptVariablesToSet[$name])) {
                continue;
            }

            $valueToConvert = false;

            if (property_exists($this->requestConfig, $name)) {
                $valueToConvert = $this->requestConfig->$name;
            } else if (property_exists($this->config, $name)) {
                $valueToConvert = $this->config->$name;
            }

            if (false !== $valueToConvert) {
                $javascriptVariablesToSet[$name] = $this->getIntIfValueIsBool($valueToConvert);
            }
        }

        $javascriptVariablesToSet['module'] = $this->config->controllerName;
        $javascriptVariablesToSet['action'] = $this->config->controllerAction;
        if (!isset($javascriptVariablesToSet['viewDataTable'])) {
            $javascriptVariablesToSet['viewDataTable'] = static::getViewDataTableId();
        }

        if ($this->dataTable &&
            // Set doesn't have the method
            !($this->dataTable instanceof DataTable\Map)
            && empty($javascriptVariablesToSet['totalRows'])
        ) {
            $javascriptVariablesToSet['totalRows'] =
                $this->dataTable->getMetadata(DataTable::TOTAL_ROWS_BEFORE_LIMIT_METADATA_NAME) ?: $this->dataTable->getRowsCount();
        }

        $deleteFromJavascriptVariables = array(
            'filter_excludelowpop',
            'filter_excludelowpop_value',
        );
        foreach ($deleteFromJavascriptVariables as $name) {
            if (isset($javascriptVariablesToSet[$name])) {
                unset($javascriptVariablesToSet[$name]);
            }
        }

        $rawSegment = \Piwik\API\Request::getRawSegmentFromRequest();
        if (!empty($rawSegment)) {
            $javascriptVariablesToSet['segment'] = $rawSegment;
        }

        return $javascriptVariablesToSet;
    }

    /**
     * Hook that is called before loading report data from the API.
     * 
     * Use this method to change the request parameters that is sent to the API when requesting
     * data.
     */
    public function beforeLoadDataTable()
    {
    }

    /**
     * Hook that is executed before generic filters are applied.
     * 
     * Use this method if you need access to the entire dataset (since generic filters will
     * limit and truncate reports).
     */
    public function beforeGenericFiltersAreAppliedToLoadedDataTable()
    {
    }

    /**
     * Hook that is executed after generic filters are applied.
     */
    public function afterGenericFiltersAreAppliedToLoadedDataTable()
    {
    }

    /**
     * Hook that is executed after the report data is loaded and after all filters have been applied.
     * Use this method to format the report data before the view is rendered.
     */
    public function afterAllFiltersAreApplied()
    {
    }

    /**
     * Hook that is executed directly before rendering. Use this hook to force display properties to
     * be a certain value, despite changes from plugins and query parameters.
     */
    public function beforeRender()
    {
        // eg $this->config->showFooterColumns = true;
    }

    /**
     * Second, generic filters (Sort, Limit, Replace Column Names, etc.)
     */
    private function applyGenericFilters()
    {
        $requestArray = $this->request->getRequestArray();
        $request      = \Piwik\API\Request::getRequestArrayFromString($requestArray);

        if (false === $this->config->enable_sort) {
            $request['filter_sort_column'] = '';
            $request['filter_sort_order']  = '';
        }

        $genericFilter = new \Piwik\API\DataTableGenericFilter($request);
        $genericFilter->filter($this->dataTable);
    }

    private function logMessageIfRequestPropertiesHaveChanged(array $requestPropertiesBefore)
    {
        $requestProperties = $this->requestConfig->getProperties();

        $diff = array_diff_assoc($this->makeSureArrayContainsOnlyStrings($requestProperties),
                                 $this->makeSureArrayContainsOnlyStrings($requestPropertiesBefore));

        if (empty($diff)) {
            return;
        }

        $details = array(
            'changedProperties' => $diff,
            'apiMethod'         => $this->requestConfig->apiMethodToRequestDataTable,
            'controller'        => $this->config->controllerName . '.' . $this->config->controllerAction,
            'viewDataTable'     => static::getViewDataTableId()
        );

        $message = 'Some ViewDataTable::requestConfig properties have changed after requesting the data table. '
                 . 'That means the changed values had probably no effect. For instance in beforeRender() hook. '
                 . 'Probably a bug? Details:'
                 . print_r($details, 1);

        Log::warning($message);
    }

    private function makeSureArrayContainsOnlyStrings($array)
    {
        $result = array();

        foreach ($array as $key => $value) {
            $result[$key] = json_encode($value);
        }

        return $result;
    }
}
