<?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\Plugin;

use Piwik\API\Proxy;
use Piwik\API\Request;
use Piwik\Columns\Dimension;
use Piwik\Common;
use Piwik\DataTable;
use Piwik\DataTable\Filter\Sort;
use Piwik\Metrics;
use Piwik\Piwik;
use Piwik\Plugins\CoreVisualizations\Visualizations\HtmlTable;
use Piwik\ViewDataTable\Factory as ViewDataTableFactory;
use Exception;
use Piwik\Widget\WidgetsList;
use Piwik\Report\ReportWidgetFactory;

/**
 * Defines a new report. This class contains all information a report defines except the corresponding API method which
 * needs to be defined in the 'API.php'. You can define the name of the report, a documentation, the supported metrics,
 * how the report should be displayed, which features the report has (eg search) and much more.
 *
 * You can create a new report using the console command `./console generate:report`. The generated report will guide
 * you through the creation of a report.
 *
 * @since 2.5.0
 * @api
 */
class Report
{
    /**
     * The sub-namespace name in a plugin where Report components are stored.
     */
    public const COMPONENT_SUBNAMESPACE = 'Reports';

    /**
     * When added to the menu, a given report eg 'getCampaigns'
     * will be routed as &action=menuGetCampaigns
     */
    public const PREFIX_ACTION_IN_MENU = 'menu';

    /**
     * The name of the module which is supposed to be equal to the name of the plugin. The module is detected
     * automatically.
     * @var string
     */
    protected $module;

    /**
     * The name of the action. The action is detected automatically depending on the file name. A corresponding action
     * should exist in the API as well.
     * @var string
     */
    protected $action;

    /**
     * The translated name of the report. The name will be used for instance in the mobile app or if another report
     * defines this report as a related report.
     * @var string
     * @api
     */
    protected $name;

    /**
     * A translated documentation which explains the report.
     * @var string
     */
    protected $documentation;

    /**
     * URL linking to an online guide for this report or plugin.
     * @var string
     */
    protected $onlineGuideUrl;

    /**
     * The translation key of the category the report belongs to.
     * @var string|null
     * @api
     */
    protected $categoryId;

    /**
     * The translation key of the subcategory the report belongs to.
     * @var string|null
     * @api
     */
    protected $subcategoryId;

    /**
     * An array of supported metrics. Eg `array('nb_visits', 'nb_actions', ...)`. Defaults to the platform default
     * metrics see {@link Metrics::getDefaultProcessedMetrics()}.
     * @var array
     * @api
     */
    protected $metrics = array('nb_visits', 'nb_uniq_visitors', 'nb_actions', 'nb_users');
    // for a little performance improvement we avoid having to call Metrics::getDefaultMetrics for each report

    /**
     * The processed metrics this report supports, eg `avg_time_on_site` or `nb_actions_per_visit`. Defaults to the
     * platform default processed metrics, see {@link Metrics::getDefaultProcessedMetrics()}. Set it to boolean `false`
     * if your report does not support any processed metrics at all. Otherwise an array of metric names.
     * Eg `array('avg_time_on_site', 'nb_actions_per_visit', ...)`
     * @var array
     * @api
     */
    protected $processedMetrics = array('nb_actions_per_visit', 'avg_time_on_site', 'bounce_rate', 'conversion_rate');
    // for a little performance improvement we avoid having to call Metrics::getDefaultProcessedMetrics for each report

    /**
     * The semantic types for all metrics this report displays (including processed metrics).
     *
     * If set to null, the defaults from the `Metrics.getDefaultMetricSemanticTypes` event are used.
     *
     * @var null|(string|null)[]
     */
    protected $metricSemanticTypes = null;

    /**
     * Set this property to true in case your report supports goal metrics. In this case, the goal metrics will be
     * automatically added to the report metadata and the report will be displayed in the Goals UI.
     * @var bool
     * @api
     */
    protected $hasGoalMetrics = false;

    /**
     * Set this property to false in case your report can't/shouldn't be flattened.
     * In this case, flattener won't be applied even if parameter is provided in a request
     * @var bool
     * @api
     */
    protected $supportsFlatten = true;

    /**
     * Set it to boolean `true` if your report always returns a constant count of rows, for instance always 24 rows
     * for 1-24 hours.
     * @var bool
     * @api
     */
    protected $constantRowsCount = false;

    /**
     * Set it to boolean `true` if this report is a subtable report and won't be used as a standalone report.
     * @var bool
     * @api
     */
    protected $isSubtableReport = false;

    /**
     * Some reports may require additional URL parameters that need to be sent when a report is requested. For instance
     * a "goal" report might need a "goalId": `array('idgoal' => 5)`.
     * @var null|array
     * @api
     */
    protected $parameters = null;

    /**
     * An instance of a dimension if the report has one. You can create a new dimension using the Piwik console CLI tool
     * if needed.
     * @var \Piwik\Columns\Dimension
     */
    protected $dimension;

    /**
     * The name of the API action to load a subtable if supported. The action has to be of the same module. For instance
     * a report "getKeywords" might support a subtable "getSearchEngines" which shows how often a keyword was searched
     * via a specific search engine.
     * @var string
     * @api
     */
    protected $actionToLoadSubTables = '';

    /**
     * The order of the report. Depending on the order the report gets a different position in the list of widgets,
     * the menu and the mobile app.
     * @var int
     * @api
     */
    protected $order = 1;

    /**
     * Separator for building recursive labels (or paths)
     * @var string
     * @api
     */
    protected $recursiveLabelSeparator = ' - ';

    /**
     * Default sort column. Either a column name or a column id.
     *
     * @var string|int
     */
    protected $defaultSortColumn = 'nb_visits';

    /**
     * Default sort desc. If true will sort by default desc, if false will sort by default asc
     *
     * @var bool
     */
    protected $defaultSortOrderDesc = true;

    /**
     * The column that uniquely identifies a row in this report. Normally
     * this is the 'label' column, but it is sometimes the case that the label column is
     * not unique. In this case, another column or metadata is used to uniquely identify a row, but
     * we don't want to display it to the user, perhaps because it is a numeric ID and not a human
     * readable value.
     *
     * This property is used by features like Row Evolution which compares the same row in
     * multiple instances of a report. Being able to find corresponding rows in reports for other
     * periods/sites/etc. is required for such features.
     *
     * @var string
     */
    protected $rowIdentifier = 'label';

    /**
     * The constructor initializes the module, action and the default metrics. If you want to overwrite any of those
     * values or if you want to do any work during initializing overwrite the method {@link init()}.
     * @ignore
     */
    final public function __construct()
    {
        $classname = get_class($this);
        $parts = explode('\\', $classname);

        if (5 === count($parts)) {
            $this->module = $parts[2];
            $this->action = lcfirst($parts[4]);
        }

        $this->init();
    }

    /**
     * Here you can do any instance initialization and overwrite any default values. You should avoid doing time
     * consuming initialization here and if possible delay as long as possible. An instance of this report will be
     * created in most page requests.
     * @api
     */
    protected function init()
    {
    }

    /**
     * Defines whether a report is enabled or not. For instance some reports might not be available to every user or
     * might depend on a setting (such as Ecommerce) of a site. In such a case you can perform any checks and then
     * return `true` or `false`. If your report is only available to users having super user access you can do the
     * following: `return Piwik::hasUserSuperUserAccess();`
     * @return bool
     * @api
     */
    public function isEnabled()
    {
        return true;
    }

    /**
     * This method checks whether the report is available, see {@isEnabled()}. If not, it triggers an exception
     * containing a message that will be displayed to the user. You can overwrite this message in case you want to
     * customize the error message. Eg.
     * ```
     * if (!$this->isEnabled()) {
     * throw new Exception('Setting XYZ is not enabled or the user has not enough permission');
     * }
     * ```
     * @throws \Exception
     * @api
     */
    public function checkIsEnabled()
    {
        if (!$this->isEnabled()) {
            throw new Exception(Piwik::translate('General_ExceptionReportNotEnabled'));
        }
    }

    /**
     * Returns the id of the default visualization for this report. Eg 'table' or 'pie'. Defaults to the HTML table.
     * @return string
     * @api
     */
    public function getDefaultTypeViewDataTable()
    {
        return HtmlTable::ID;
    }

    /**
     * Returns if the default viewDataTable type should always be used. e.g. the type won't be changeable through config or url params.
     * Defaults to false
     * @return bool
     */
    public function alwaysUseDefaultViewDataTable()
    {
        return false;
    }

    /**
     * Here you can configure how your report should be displayed and which capabilities your report has. For instance
     * whether your report supports a "search" or not. EG `$view->config->show_search = false`. You can also change the
     * default request config. For instance you can change how many rows are displayed by default:
     * `$view->requestConfig->filter_limit = 10;`. See {@link ViewDataTable} for more information.
     * @api
     */
    public function configureView(ViewDataTable $view)
    {
    }

    /**
     * Renders a report depending on the configured ViewDataTable see {@link configureView()} and
     * {@link getDefaultTypeViewDataTable()}. If you want to customize the render process or just render any custom view
     * you can overwrite this method.
     *
     * @return string
     * @throws \Exception In case the given API action does not exist yet.
     * @api
     */
    public function render()
    {
        $viewDataTable = Common::getRequestVar('viewDataTable', false, 'string');
        $fixed = Common::getRequestVar('forceView', 0, 'int');

        $module = $this->getModule();
        $action = $this->getAction();

        $apiProxy = Proxy::getInstance();

        if (!$apiProxy->isExistingApiAction($module, $action)) {
            throw new Exception("Invalid action name '$action' for '$module' plugin.");
        }

        $apiAction = $apiProxy->buildApiActionName($module, $action);

        $view = ViewDataTableFactory::build($viewDataTable, $apiAction, $module . '.' . $action, $fixed);

        return $view->render();
    }

    /**
     * Processing a uniqueId for each report, can be used by UIs as a key to match a given report
     * @return string
     */
    public function getId()
    {
        $params = $this->getParameters();

        $paramsKey = $this->getModule() . '.' . $this->getAction();

        if (!empty($params)) {
            foreach ($params as $key => $value) {
                $paramsKey .= '_' . $key . '--' . $value;
            }
        }

        return $paramsKey;
    }

    /**
     * lets you add any amount of widgets for this report. If a report defines a {@link $categoryId} and a
     * {@link $subcategoryId} a widget will be generated automatically.
     *
     * Example to add a widget manually by overwriting this method in your report:
     * $widgetsList->addWidgetConfig($factory->createWidget());
     *
     * If you want to have the name and the order of the widget differently to the name and order of the report you can
     * do the following:
     * $widgetsList->addWidgetConfig($factory->createWidget()->setName('Custom')->setOrder(5));
     *
     * If you want to add a widget to any container defined by your plugin or by another plugin you can do
     * this:
     * $widgetsList->addToContainerWidget($containerId = 'Products', $factory->createWidget());
     *
     * @api
     */
    public function configureWidgets(WidgetsList $widgetsList, ReportWidgetFactory $factory)
    {
        if ($this->categoryId && $this->subcategoryId) {
            $widgetsList->addWidgetConfig($factory->createWidget());
        }
    }

    /**
     * @ignore
     * @see $recursiveLabelSeparator
     */
    public function getRecursiveLabelSeparator()
    {
        return $this->recursiveLabelSeparator;
    }

    /**
     * Returns an array of supported metrics and their corresponding translations. Eg `array('nb_visits' => 'Visits')`.
     * By default the given {@link $metrics} are used and their corresponding translations are looked up automatically.
     * If a metric is not translated, you should add the default metric translation for this metric using
     * the {@hook Metrics.getDefaultMetricTranslations} event. If you want to overwrite any default metric translation
     * you should overwrite this method, call this parent method to get all default translations and overwrite any
     * custom metric translations.
     * @return array
     * @api
     */
    public function getMetrics()
    {
        return $this->getMetricTranslations($this->metrics);
    }

    /**
     * Returns the list of metrics required at minimum for a report factoring in the columns requested by
     * the report requester.
     *
     * This will return all the metrics requested (or all the metrics in the report if nothing is requested)
     * **plus** the metrics required to calculate the requested processed metrics.
     *
     * This method should be used in **Plugin.get** API methods.
     *
     * @param string[]|null $allMetrics The list of all available unprocessed metrics. Defaults to this report's
     *                                  metrics.
     * @param string[]|null $restrictToColumns The requested columns.
     * @return string[]
     */
    public function getMetricsRequiredForReport($allMetrics = null, $restrictToColumns = null)
    {
        if (empty($allMetrics)) {
            $allMetrics = $this->metrics;
        }

        if (empty($restrictToColumns)) {
            $restrictToColumns = array_merge($allMetrics, array_keys($this->getProcessedMetrics()));
        }
        $restrictToColumns = array_unique($restrictToColumns);

        $processedMetricsById = $this->getProcessedMetricsById();
        $metricsSet = array_flip($allMetrics);

        $metrics = array();
        foreach ($restrictToColumns as $column) {
            if (isset($processedMetricsById[$column])) {
                $metrics = array_merge($metrics, $processedMetricsById[$column]->getDependentMetrics());
            } elseif (isset($metricsSet[$column])) {
                $metrics[] = $column;
            }
        }
        return array_unique($metrics);
    }

    /**
     * Returns an array of supported processed metrics and their corresponding translations. Eg
     * `array('nb_visits' => 'Visits')`. By default the given {@link $processedMetrics} are used and their
     * corresponding translations are looked up automatically. If a metric is not translated, you should add the
     * default metric translation for this metric using the {@hook Metrics.getDefaultMetricTranslations} event. If you
     * want to overwrite any default metric translation you should overwrite this method, call this parent method to
     * get all default translations and overwrite any custom metric translations.
     * @return array|mixed
     * @api
     */
    public function getProcessedMetrics()
    {
        if (!is_array($this->processedMetrics)) {
            return $this->processedMetrics;
        }

        return $this->getMetricTranslations($this->processedMetrics);
    }

    /**
     * Returns the semantic types for metrics this report displays.
     *
     * If the semantic type is not defined by the derived Report class, it defaults to
     * the value returned by {@link Metrics::getDefaultMetricSemanticTypes()} or
     * {@link Metric::getSemanticType()}. If the semantic type cannot be found this way,
     * this method tries to deduce it from the metric name, though this process will
     * not identify the semantic type for most metrics.
     *
     * @return string[] maps metric name => semantic type
     * @api
     */
    public function getMetricSemanticTypes(): array
    {
        $metricTypes = $this->metricSemanticTypes ?: [];

        $allMetrics = array_merge($this->metrics ?: [], $this->processedMetrics ?: []);

        foreach ($allMetrics as $metric) {
            $metricName = $metric instanceof Metric ? $metric->getName() : $metric;
            if (
                $metricName == 'label'
                || !empty($metricTypes[$metricName])
            ) {
                continue;
            }

            $metricTypes[$metricName] = $this->deduceMetricTypeFromName($metric);
        }

        return $metricTypes;
    }

    /**
     * Returns the array of all metrics displayed by this report.
     *
     * @return array
     * @api
     */
    public function getAllMetrics()
    {
        $processedMetrics = $this->getProcessedMetrics() ?: array();
        return array_keys(array_merge($this->getMetrics(), $processedMetrics));
    }

    /**
     * Use this method to register metrics to process report totals.
     *
     * When a metric is registered, it will process the report total values and as a result show percentage values
     * in the HTML Table reporting visualization.
     *
     * @return string[]  metricId => metricColumn, if the report has only column names and no IDs, it should return
     *                   metricColumn => metricColumn, eg array('13' => 'nb_pageviews') or array('mymetric' => 'mymetric')
     */
    public function getMetricNamesToProcessReportTotals()
    {
        return array();
    }

    /**
     * Returns an array of metric documentations and their corresponding translations. Eg
     * `array('nb_visits' => 'If a visitor comes to your website for the first time or if they visit a page more than 30 minutes after...')`.
     * By default the given {@link $metrics} are used and their corresponding translations are looked up automatically.
     * If there is a metric documentation not found, you should add the default metric documentation translation for
     * this metric using the {@hook Metrics.getDefaultMetricDocumentationTranslations} event. If you want to overwrite
     * any default metric translation you should overwrite this method, call this parent method to get all default
     * translations and overwrite any custom metric translations.
     * @return array
     * @api
     */
    protected function getMetricsDocumentation()
    {
        $translations  = Metrics::getDefaultMetricsDocumentation();
        $documentation = array();

        foreach ($this->metrics as $metric) {
            if (is_string($metric) && !empty($translations[$metric])) {
                $documentation[$metric] = $translations[$metric];
            } elseif ($metric instanceof Metric) {
                $name = $metric->getName();
                $metricDocs = $metric->getDocumentation();
                if (empty($metricDocs) && !empty($translations[$name])) {
                    $metricDocs = $translations[$name];
                }

                if (!empty($metricDocs)) {
                    $documentation[$name] = $metricDocs;
                }
            }
        }

        $processedMetrics = $this->processedMetrics ?: array();
        foreach ($processedMetrics as $processedMetric) {
            if (is_string($processedMetric) && !empty($translations[$processedMetric])) {
                $documentation[$processedMetric] = $translations[$processedMetric];
            } elseif ($processedMetric instanceof Metric) {
                $name = $processedMetric->getName();
                $metricDocs = $processedMetric->getDocumentation();
                if (empty($metricDocs) && !empty($translations[$name])) {
                    $metricDocs = $translations[$name];
                }

                if (!empty($metricDocs)) {
                    $documentation[$name] = $metricDocs;
                }
            }
        }

        return $documentation;
    }

    /**
     * @return bool
     * @ignore
     */
    public function hasGoalMetrics()
    {
        return $this->hasGoalMetrics;
    }

    /**
     * @return bool
     * @ignore
     */
    public function supportsFlatten()
    {
        return $this->supportsFlatten;
    }

    /**
     * @ignore
     */
    public function hasConstantRowsCount(): bool
    {
        return $this->constantRowsCount;
    }

    /**
     * If the report is enabled the report metadata for this report will be built and added to the list of available
     * reports. Overwrite this method and leave it empty in case you do not want your report to be added to the report
     * metadata. In this case your report won't be visible for instance in the mobile app and scheduled reports
     * generator. We recommend to change this behavior only if you are familiar with the Piwik core. `$infos` contains
     * the current requested date, period and site.
     * @param $availableReports
     * @param $infos
     * @api
     */
    public function configureReportMetadata(&$availableReports, $infos)
    {
        if (!$this->isEnabled()) {
            return;
        }

        $report = $this->buildReportMetadata();

        if (!empty($report)) {
            $availableReports[] = $report;
        }
    }

    /**
     * Get report documentation.
     * @return string
     */
    public function getDocumentation()
    {
        return $this->documentation;
    }

    /**
     * Builts the report metadata for this report. Can be useful in case you want to change the behavior of
     * {@link configureReportMetadata()}.
     * @return ?array
     * @ignore
     *
     * TODO we should move this out to API::getReportMetadata
     */
    protected function buildReportMetadata()
    {
        $report = array(
            'category' => $this->getCategoryId(),
            'subcategory' => $this->getSubcategoryId(),
            'name'     => $this->getName(),
            'module'   => $this->getModule(),
            'action'   => $this->getAction(),
        );

        if (null !== $this->parameters) {
            $report['parameters'] = $this->parameters;
        }

        if (!empty($this->dimension)) {
            $report['dimension'] = $this->dimension->getName();
        }

        if (!empty($this->documentation)) {
            $report['documentation'] = $this->documentation;
        }

        if (!empty($this->onlineGuideUrl)) {
            $report['onlineGuideUrl'] = $this->onlineGuideUrl;
        }

        if (true === $this->isSubtableReport) {
            $report['isSubtableReport'] = $this->isSubtableReport;
        }

        $dimensions = $this->getDimensions();

        if (count($dimensions) > 1) {
            $report['dimensions'] = $dimensions;
        }

        $report['metrics']              = $this->getMetrics();
        $report['metricsDocumentation'] = $this->getMetricsDocumentation();
        $report['processedMetrics']     = $this->getProcessedMetrics();

        $report['metricTypes'] = $this->getMetricSemanticTypes();
        $report['metricTypes'] = array_map(function ($t) {
            return $t ?: 'unspecified';
        }, $report['metricTypes']);

        if (!empty($this->actionToLoadSubTables)) {
            $report['actionToLoadSubTables'] = $this->actionToLoadSubTables;
        }

        if (true === $this->constantRowsCount) {
            $report['constantRowsCount'] = $this->constantRowsCount;
        }

        $relatedReports = $this->getRelatedReports();
        if (!empty($relatedReports)) {
            $report['relatedReports'] = array();
            foreach ($relatedReports as $relatedReport) {
                if (!empty($relatedReport)) {
                    $report['relatedReports'][] = [
                        'name'   => $relatedReport->getName(),
                        'module' => $relatedReport->getModule(),
                        'action' => $relatedReport->getAction(),
                    ];
                }
            }
        }

        $report['order'] = $this->order;

        return $report;
    }

    /**
     * @ignore
     */
    public function getDefaultSortColumn()
    {
        return $this->defaultSortColumn;
    }

    /**
     * @ignore
     */
    public function getDefaultSortOrder()
    {
        if ($this->defaultSortOrderDesc) {
            return Sort::ORDER_DESC;
        }

        return Sort::ORDER_ASC;
    }

    /**
     * Allows to define a callback that will be used to determine the secondary column to sort by
     *
     * ```
     * public function getSecondarySortColumnCallback()
     * {
     *     return function ($primaryColumn) {
     *         switch ($primaryColumn) {
     *             case Metrics::NB_CLICKS:
     *                 return Metrics::NB_IMPRESSIONS;
     *             case 'label':
     *             default:
     *                 return Metrics::NB_CLICKS;
     *         }
     *     };
     * }
     * ```
     * @return null|callable
     */
    public function getSecondarySortColumnCallback()
    {
        return null;
    }

    /**
     * Get the list of related reports if there are any. They will be displayed for instance below a report as a
     * recommended related report.
     *
     * @return (Report|null)[]
     * @api
     */
    public function getRelatedReports()
    {
        return array();
    }

    /**
     * Get the name of the report
     * @return string
     * @ignore
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * Get the name of the module.
     * @return string
     * @ignore
     */
    public function getModule()
    {
        return $this->module;
    }

    /**
     * Get the name of the action.
     * @return string
     * @ignore
     */
    public function getAction()
    {
        return $this->action;
    }

    public function getParameters()
    {
        return $this->parameters;
    }

    /**
     * Get the translated name of the category the report belongs to.
     * @return string|null
     * @ignore
     */
    public function getCategoryId()
    {
        return $this->categoryId;
    }

    /**
     * Get the translated name of the subcategory the report belongs to.
     * @return string|null
     * @ignore
     */
    public function getSubcategoryId()
    {
        return $this->subcategoryId;
    }

    /**
     * @return \Piwik\Columns\Dimension|null
     * @ignore
     */
    public function getDimension()
    {
        return $this->dimension;
    }

    /**
     * Get dimensions used for current report and its subreports
     *
     * @return array [dimensionId => dimensionName]
     * @ignore
     */
    public function getDimensions()
    {
        $dimensions = [];
        $tableDimensionId = null;

        if (!empty($this->getDimension())) {
            $tableDimensionId = $this->getDimension()->getId();
            $dimensionId = str_replace('.', '_', $tableDimensionId);
            $dimensions[$dimensionId] = $this->getDimension()->getName();
        }

        for ($level = 1; $level <= 100; $level++) {
            $subDimension = $this->getNthLevelTableDimension($level);
            if (empty($subDimension) || $tableDimensionId === $subDimension->getId()) {
                break;
            }
            $tableDimensionId = $subDimension->getId();
            $subDimensionId = str_replace('.', '_', $tableDimensionId);
            $dimensions[$subDimensionId] = $subDimension->getName();
        };

        return $dimensions;
    }

    /**
     * Returns the order of the report
     * @return int
     * @ignore
     */
    public function getOrder()
    {
        return $this->order;
    }

    /**
     * Get the action to load sub tables if one is defined.
     * @return string
     * @ignore
     */
    public function getActionToLoadSubTables()
    {
        return $this->actionToLoadSubTables;
    }

    /**
     * Returns the Dimension instance of this report's subtable report.
     *
     * @return Dimension|null The subtable report's dimension or null if there is subtable report or
     *                        no dimension for the subtable report.
     * @api
     */
    public function getSubtableDimension()
    {
        return $this->getNthLevelTableDimension($level = 1);
    }

    /**
     * Returns the Dimension instance of the subtable report of this report's subtable report.
     *
     * @return Dimension|null The subtable report's dimension or null if there is no subtable report or
     *                        no dimension for the subtable report.
     * @deprecated since 5.3.0, use getNthLevelTableDimension(2) instead
     * @api
     */
    public function getThirdLeveltableDimension()
    {
        return $this->getNthLevelTableDimension($level = 2);
    }

    /**
     * Returns the Dimension instance of the subtable report of this report's subtable report based on level.
     *
     * @param int $level The subTable level for which dimension is to be determined, zero-based
     * @return Dimension|null The subtable report's dimension or null if there is no subtable report or
     *                        no dimension for the subtable report.
     * @api
     */
    public function getNthLevelTableDimension(int $level): ?Dimension
    {
        if (empty($this->actionToLoadSubTables)) {
            return null;
        }

        $subTableReport = $this;
        for ($i = 1; $i <= $level; $i++) {
            [$subTableReportModule, $subTableReportAction] = $subTableReport->getSubtableApiMethod();
            $subTableReport = ReportsProvider::factory($subTableReportModule, $subTableReportAction);

            if (empty($subTableReport)) {
                return null;
            }
        }

        return $subTableReport->getDimension();
    }

    /**
     * Returns true if the report is for another report's subtable, false if otherwise.
     *
     * @return bool
     */
    public function isSubtableReport()
    {
        return $this->isSubtableReport;
    }

    /**
     * Fetches the report represented by this instance.
     *
     * @param array $paramOverride Query parameter overrides.
     * @return DataTable
     * @api
     */
    public function fetch($paramOverride = array())
    {
        return Request::processRequest($this->module . '.' . $this->action, $paramOverride);
    }

    /**
     * Fetches a subtable for the report represented by this instance.
     *
     * @param int $idSubtable The subtable ID.
     * @param array $paramOverride Query parameter overrides.
     * @return DataTable
     * @api
     */
    public function fetchSubtable($idSubtable, $paramOverride = array())
    {
        $paramOverride = array('idSubtable' => $idSubtable) + $paramOverride;

        [$module, $action] = $this->getSubtableApiMethod();
        return Request::processRequest($module . '.' . $action, $paramOverride);
    }

    private function getMetricTranslations($metricsToTranslate)
    {
        $translations = Metrics::getDefaultMetricTranslations();
        $metrics = array();

        foreach ($metricsToTranslate as $metric) {
            if ($metric instanceof Metric) {
                $metricName  = $metric->getName();
                $translation = $metric->getTranslatedName();
            } else {
                $metricName  = $metric;
                $translation = $translations[$metric] ?? null;
            }

            $metrics[$metricName] = $translation ?: $metricName;
        }

        return $metrics;
    }

    private function getSubtableApiMethod()
    {
        if (strpos($this->actionToLoadSubTables, '.') !== false) {
            return explode('.', $this->actionToLoadSubTables);
        } else {
            return array($this->module, $this->actionToLoadSubTables);
        }
    }

    /**
     * Finds a top level report that provides stats for a specific Dimension.
     *
     * @param Dimension $dimension The dimension whose report we're looking for.
     * @return Report|null The
     * @api
     */
    public static function getForDimension(Dimension $dimension)
    {
        $provider = new ReportsProvider();
        $reports = $provider->getAllReports();
        foreach ($reports as $report) {
            if (
                !$report->isSubtableReport()
                && $report->getDimension()
                && $report->getDimension()->getId() == $dimension->getId()
            ) {
                return $report;
            }
        }
        return null;
    }

    /**
     * Returns an array mapping the ProcessedMetrics served by this report by their string names.
     *
     * @return ProcessedMetric[]
     */
    public function getProcessedMetricsById()
    {
        $processedMetrics = $this->processedMetrics ?: array();

        $result = array();
        foreach ($processedMetrics as $processedMetric) {
            if ($processedMetric instanceof ProcessedMetric) { // instanceof check for backwards compatibility
                $result[$processedMetric->getName()] = $processedMetric;
            } elseif (
                $processedMetric instanceof ArchivedMetric
                && $processedMetric->getType() !== Dimension::TYPE_NUMBER
                && $processedMetric->getType() !== Dimension::TYPE_FLOAT
                && $processedMetric->getType() !== Dimension::TYPE_BOOL
                && $processedMetric->getType() !== Dimension::TYPE_ENUM
            ) {
                // we do not format regular numbers from regular archived metrics here because when they are rendered
                // in a visualisation (eg HtmlTable) they would be formatted again in the regular number filter.
                // These metrics aren't "processed metrics". Eventually could maybe format them when "&format_metrics=all"
                // is used but may not be needed. It caused a problem when eg language==de. Then eg 555444 would be formatted
                // to "555.444" (which is the German version of the English "555,444") in the data table post processor
                // when formatting metrics. Then when rendering the visualisation it would check "is_numeric()" which is
                // true for German formatting but false for English formatting. Meaning for English formatting the number
                // would be correctly printed as is but for the German formatting it would format it again and it would think
                // it would be assumed the dot is a decimal separator and therefore the number be formatted to "555,44" which
                // is the English version of "555.44" (because we only show 2 fractions).
                $result[$processedMetric->getName()] = $processedMetric;
            }
        }
        return $result;
    }

    /**
     * Returns the Metrics that are displayed by a DataTable of a certain Report type.
     *
     * Includes ProcessedMetrics and Metrics.
     *
     * @param string $baseType The base type each metric class needs to be of.
     * @return Metric[]
     * @api
     */
    public static function getMetricsForTable(DataTable $dataTable, ?Report $report = null, $baseType = 'Piwik\\Plugin\\Metric')
    {
        $metrics = $dataTable->getMetadata(DataTable::EXTRA_PROCESSED_METRICS_METADATA_NAME) ?: array();

        if (!empty($report)) {
            $metrics = array_merge($metrics, $report->getProcessedMetricsById());
        }

        $result = array();

        /** @var Metric $metric */
        foreach ($metrics as $metric) {
            if (!($metric instanceof $baseType)) {
                continue;
            }

            $result[$metric->getName()] = $metric;
        }

        return $result;
    }

    /**
     * Returns the ProcessedMetrics that should be computed and formatted for a DataTable of a
     * certain report. The ProcessedMetrics returned are those specified by the Report metadata
     * as well as the DataTable metadata.
     *
     * @return ProcessedMetric[]
     * @api
     */
    public static function getProcessedMetricsForTable(DataTable $dataTable, ?Report $report = null)
    {
        /** @var ProcessedMetric[] $metrics */
        $metrics = self::getMetricsForTable($dataTable, $report, 'Piwik\\Plugin\\ProcessedMetric');

        // sort metrics w/ dependent metrics calculated before the metrics that depend on them
        $result = [];
        self::processedMetricDfs($metrics, function ($metricName) use (&$result, $metrics) {
            $result[$metricName] = $metrics[$metricName];
        });
        return $result;
    }

    /**
     * @param ProcessedMetric[] $metrics
     * @param $callback
     * @param array $visited
     */
    private static function processedMetricDfs($metrics, $callback, &$visited = [], $toVisit = null)
    {
        $toVisit = $toVisit === null ? $metrics : $toVisit;
        foreach ($toVisit as $name => $metric) {
            if (!empty($visited[$name])) {
                continue;
            }

            $visited[$name] = true;

            $dependentMetrics = [];
            foreach ($metric->getDependentMetrics() as $metricName) {
                if (!empty($metrics[$metricName])) {
                    $dependentMetrics[$metricName] = $metrics[$metricName];
                }
            }

            self::processedMetricDfs($metrics, $callback, $visited, $dependentMetrics);

            $callback($name);
        }
    }

    /**
     * Returns the name of the column/metadata that uniquely identifies rows in this report. See
     * {@link self::$rowIdentifier} for more information.
     */
    public function getRowIdentifier(): string
    {
        return $this->rowIdentifier;
    }

    private function deduceMetricTypeFromName($metric): ?string
    {
        $metricName = $metric instanceof Metric ? $metric->getName() : $metric;

        $metricType = null;
        if ($metric instanceof Metric) {
            $metricType = $metric->getSemanticType();
        }

        if (empty($metricType)) {
            if (preg_match('/_(evolution|rate|percentage)(_|$)/', $metricName)) {
                $metricType = Dimension::TYPE_PERCENT;
            } else {
                $allMetricTypes = Metrics::getDefaultMetricSemanticTypes();
                $metricType = $allMetricTypes[$metricName] ?? null;
            }
        }

        return $metricType;
    }
}
