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

use Exception;
use Piwik\DataTable\BaseFilter;
use Piwik\DataTable\DataTableInterface;
use Piwik\DataTable\Manager;
use Piwik\DataTable\Renderer\Html;
use Piwik\DataTable\Row;
use Piwik\DataTable\Row\DataTableSummaryRow;
use Piwik\DataTable\Simple;
use ReflectionClass;

/**
 * @see Common::destroy()
 */
require_once PIWIK_INCLUDE_PATH . '/core/Common.php';
require_once PIWIK_INCLUDE_PATH . "/core/DataTable/Bridges.php";

/**
 * The primary data structure used to store analytics data in Piwik.
 *
 * <a name="class-desc-the-basics"></a>
 * ### The Basics
 *
 * DataTables consist of rows and each row consists of columns. A column value can be
 * a numeric, a string or an array.
 *
 * Every row has an ID. The ID is either the index of the row or {@link ID_SUMMARY_ROW}.
 *
 * DataTables are hierarchical data structures. Each row can also contain an additional
 * nested sub-DataTable (commonly referred to as a 'subtable').
 *
 * Both DataTables and DataTable rows can hold **metadata**. _DataTable metadata_ is information
 * regarding all the data, such as the site or period that the data is for. _Row metadata_
 * is information regarding that row, such as a browser logo or website URL.
 *
 * Finally, all DataTables contain a special _summary_ row. This row, if it exists, is
 * always at the end of the DataTable.
 *
 * ### Populating DataTables
 *
 * Data can be added to DataTables in three different ways. You can either:
 *
 * 1. create rows one by one and add them through {@link addRow()} then truncate if desired,
 * 2. create an array of DataTable\Row instances or an array of arrays and add them using
 *    {@link addRowsFromArray()} or {@link addRowsFromSimpleArray()}
 *    then truncate if desired,
 * 3. or set the maximum number of allowed rows (with {@link setMaximumAllowedRows()})
 *    and add rows one by one.
 *
 * If you want to eventually truncate your data (standard practice for all Piwik plugins),
 * the third method is the most memory efficient. It is, unfortunately, not always possible
 * to use since it requires that the data be sorted before adding.
 *
 * ### Manipulating DataTables
 *
 * There are two ways to manipulate a DataTable. You can either:
 *
 * 1. manually iterate through each row and manipulate the data,
 * 2. or you can use predefined filters.
 *
 * A filter is a class that has a 'filter' method which will manipulate a DataTable in
 * some way. There are several predefined Filters that allow you to do common things,
 * such as,
 *
 * - add a new column to each row,
 * - add new metadata to each row,
 * - modify an existing column value for each row,
 * - sort an entire DataTable,
 * - and more.
 *
 * Using these filters instead of writing your own code will increase code clarity and
 * reduce code redundancy. Additionally, filters have the advantage that they can be
 * applied to DataTable\Map instances. So you can visit every DataTable in a {@link DataTable\Map}
 * without having to write a recursive visiting function.
 *
 * All predefined filters exist in the **Piwik\DataTable\BaseFilter** namespace.
 *
 * _Note: For convenience, [anonymous functions](https://www.php.net/manual/en/functions.anonymous.php)
 * can be used as DataTable filters._
 *
 * ### Applying Filters
 *
 * Filters can be applied now (via {@link filter()}), or they can be applied later (via
 * {@link queueFilter()}).
 *
 * Filters that sort rows or manipulate the number of rows should be applied right away.
 * Non-essential, presentation filters should be queued.
 *
 * ### Learn more
 *
 * - See **{@link ArchiveProcessor}** to learn how DataTables are persisted.
 *
 * ### Examples
 *
 * **Populating a DataTable**
 *
 *     // adding one row at a time
 *     $dataTable = new DataTable();
 *     $dataTable->addRow(new Row(array(
 *         Row::COLUMNS => array('label' => 'thing1', 'nb_visits' => 1, 'nb_actions' => 1),
 *         Row::METADATA => array('url' => 'http://thing1.com')
 *     )));
 *     $dataTable->addRow(new Row(array(
 *         Row::COLUMNS => array('label' => 'thing2', 'nb_visits' => 2, 'nb_actions' => 2),
 *         Row::METADATA => array('url' => 'http://thing2.com')
 *     )));
 *
 *     // using an array of rows
 *     $dataTable = new DataTable();
 *     $dataTable->addRowsFromArray(array(
 *         array(
 *             Row::COLUMNS => array('label' => 'thing1', 'nb_visits' => 1, 'nb_actions' => 1),
 *             Row::METADATA => array('url' => 'http://thing1.com')
 *         ),
 *         array(
 *             Row::COLUMNS => array('label' => 'thing2', 'nb_visits' => 2, 'nb_actions' => 2),
 *             Row::METADATA => array('url' => 'http://thing2.com')
 *         )
 *     ));
 *
 *     // using a "simple" array
 *     $dataTable->addRowsFromSimpleArray(array(
 *         array('label' => 'thing1', 'nb_visits' => 1, 'nb_actions' => 1),
 *         array('label' => 'thing2', 'nb_visits' => 2, 'nb_actions' => 2)
 *     ));
 *
 * **Getting & setting metadata**
 *
 *     $dataTable = \Piwik\Plugins\Referrers\API::getInstance()->getSearchEngines($idSite = 1, $period = 'day', $date = '2007-07-24');
 *     $oldPeriod = $dataTable->metadata['period'];
 *     $dataTable->metadata['period'] = Period\Factory::build('week', Date::factory('2013-10-18'));
 *
 * **Serializing & unserializing**
 *
 *     $maxRowsInTable = Config::getInstance()->General['datatable_archiving_maximum_rows_standard'];j
 *
 *     $dataTable = // ... build by aggregating visits ...
 *     $serializedData = $dataTable->getSerialized($maxRowsInTable, $maxRowsInSubtable = $maxRowsInTable,
 *                                                 $columnToSortBy = Metrics::INDEX_NB_VISITS);
 *
 *     $serializedDataTable = $serializedData[0];
 *     $serailizedSubTable = $serializedData[$idSubtable];
 *
 * **Filtering for an API method**
 *
 *     public function getMyReport($idSite, $period, $date, $segment = false, $expanded = false)
 *     {
 *         $dataTable = Archive::createDataTableFromArchive('MyPlugin_MyReport', $idSite, $period, $date, $segment, $expanded);
 *         $dataTable->filter('Sort', array(Metrics::INDEX_NB_VISITS, 'desc', $naturalSort = false, $expanded));
 *         $dataTable->queueFilter('ColumnCallbackAddMetadata', array('label', 'url', __NAMESPACE__ . '\getUrlFromLabelForMyReport'));
 *         return $dataTable;
 *     }
 *
 * @implements \IteratorAggregate<int, Row>
 * @implements \ArrayAccess<int, Row>
 * @api
 */
class DataTable implements DataTableInterface, \IteratorAggregate, \ArrayAccess
{
    public const MAX_DEPTH_DEFAULT = 15;

    /** Name for metadata that describes the archiving state of a report */
    public const ARCHIVE_STATE_METADATA_NAME = 'archive_state';

    /** Name for metadata that describes when a report was archived. */
    public const ARCHIVED_DATE_METADATA_NAME = 'ts_archived';

    /** Name for metadata that describes which columns are empty and should not be shown. */
    public const EMPTY_COLUMNS_METADATA_NAME = 'empty_column';

    /** Name for metadata that describes the number of rows that existed before the Limit filter was applied. */
    public const TOTAL_ROWS_BEFORE_LIMIT_METADATA_NAME = 'total_rows_before_limit';

    /**
     * Name for metadata that describes how individual columns should be aggregated when {@link addDataTable()}
     * or {@link Piwik\DataTable\Row::sumRow()} is called.
     *
     * This metadata value must be an array that maps column names with valid operations. Valid aggregation operations are:
     *
     * - `'skip'`: do nothing
     * - `'max'`: does `max($column1, $column2)`
     * - `'min'`: does `min($column1, $column2)`
     * - `'sum'`: does `$column1 + $column2`
     *
     * See {@link addDataTable()} and {@link DataTable\Row::sumRow()} for more information.
     */
    public const COLUMN_AGGREGATION_OPS_METADATA_NAME = 'column_aggregation_ops';

    /**
     * Name for metadata that stores array of generic filters that should not be run on the table.
     */
    public const GENERIC_FILTERS_TO_DISABLE_METADATA_NAME = 'generic_filters_to_disable';

    /**
     * Name for metadata that describes whether the totals row only totals the rows matching the
     * table search of the request, instead of every row of the report.
     *
     * The report totals stay available in the `totals` metadata when this is set.
     */
    public const TOTALS_ROW_IS_FILTERED_METADATA_NAME = 'totalsRowIsFiltered';

    /** The ID of the Summary Row. */
    public const ID_SUMMARY_ROW = -1;

    /**
     * The ID of the special metadata row. This row only exists in the serialized row data and stores the datatable metadata.
     *
     * This allows us to save datatable metadata in archive data.
     */
    public const ID_ARCHIVED_METADATA_ROW = -3;

    /** The original label of the Summary Row. */
    public const LABEL_SUMMARY_ROW = -1;
    public const LABEL_TOTALS_ROW = -2;
    public const LABEL_ARCHIVED_METADATA_ROW = '__datatable_metadata__';

    /**
     * Name for metadata that contains extra {@link Piwik\Plugin\ProcessedMetric}s for a DataTable.
     * These metrics will be added in addition to the ones specified in the table's associated
     * {@link Piwik\Plugin\Report} class.
     */
    public const EXTRA_PROCESSED_METRICS_METADATA_NAME = 'extra_processed_metrics';

    public const ROW_IDENTIFIER_METADATA_NAME = 'rowIdentifier';

    /**
     * Maximum nesting level.
     * @var int
     */
    private static $maximumDepthLevelAllowed = self::MAX_DEPTH_DEFAULT;

    /**
     * Array of Row
     *
     * @var Row[]|null
     */
    protected $rows = [];

    /**
     * Id assigned to the DataTable, used to lookup the table using the DataTable_Manager
     *
     * @var int
     */
    protected $currentId;

    /**
     * This flag is set to false once we modify the table in a way that outdates the index
     *
     * @var bool
     */
    protected $indexNotUpToDate = true;

    /**
     * This flag sets the index to be rebuild whenever a new row is added,
     * as opposed to re-building the full index when getRowFromLabel is called.
     * This is to optimize and not rebuild the full Index in the case where we
     * add row, getRowFromLabel, addRow, getRowFromLabel thousands of times.
     *
     * @var bool
     */
    protected $rebuildIndexContinuously = false;

    /**
     * Column name of last time the table was sorted
     *
     * @var string|false
     */
    protected $tableSortedBy = false;

    /**
     * List of BaseFilter queued to this table
     *
     * @var array<array{className: string|callable, parameters: array<scalar, mixed>}>
     */
    protected $queuedFilters = array();

    /**
     * List of disabled filter names eg 'Limit' or 'Sort'
     *
     * @var String[]
     */
    protected $disabledFilters = array();

    /**
     * Defaults to false for performance reasons (most of the time we don't need recursive sorting so we save a looping over the dataTable)
     *
     * @var bool
     */
    protected $enableRecursiveSort = false;

    /**
     * When the table and all subtables are loaded, this flag will be set to true to ensure filters are applied to all subtables
     *
     * @var bool
     */
    protected $enableRecursiveFilters = false;

    /**
     * @var array<string, int>
     */
    protected $rowsIndexByLabel = array();

    /**
     * @var Row|null
     */
    protected $summaryRow = null;

    /**
     * @var Row|null
     */
    protected $totalsRow = null;

    /**
     * Table metadata. Read [this](#class-desc-the-basics) to learn more.
     *
     * Any data that describes the data held in the table's rows should go here.
     *
     * Note: this field is protected so derived classes will serialize it.
     *
     * @var array<string, mixed>
     */
    protected $metadata = array();

    /**
     * Maximum number of rows allowed in this datatable (including the summary row).
     * If adding more rows is attempted, the extra rows get summed to the summary row.
     *
     * @var int
     */
    protected $maximumAllowedRows = 0;

    /** @var bool */
    protected $isBuiltWithoutArchives = true;

    /**
     * Constructor. Creates an empty DataTable.
     */
    public function __construct()
    {
        // registers this instance to the manager
        $this->currentId = Manager::getInstance()->addTable($this);
    }

    /**
     * Destructor. Makes sure DataTable memory will be cleaned up.
     */
    public function __destruct()
    {
        static $depth = 0;
        // destruct can be called several times
        if (
            $depth < self::$maximumDepthLevelAllowed
            && isset($this->rows)
        ) {
            $depth++;
            foreach ($this->rows as $row) {
                Common::destroy($row);
            }
            if (isset($this->summaryRow)) {
                Common::destroy($this->summaryRow);
            }
            unset($this->rows);
            Manager::getInstance()->setTableDeleted($this->currentId);
            $depth--;
        }
    }

    /**
     * Clone. Called when cloning the datatable. We need to make sure to create a new datatableId.
     * If we do not increase tableId it can result in segmentation faults when destructing a datatable.
     */
    public function __clone()
    {
        // registers this instance to the manager
        $this->currentId = Manager::getInstance()->addTable($this);
    }

    /**
     * @return void
     */
    public function setLabelsHaveChanged()
    {
        $this->indexNotUpToDate = true;
    }

    /**
     * does not update the summary row!
     * @param Row[]|null $rows
     * @return void
     * @ignore
     */
    public function setRows($rows)
    {
        unset($this->rows);
        $this->rows = (is_array($rows) ? $rows : []);
        $this->indexNotUpToDate = true;
    }

    /**
     * Sorts the DataTable rows using the supplied callback function.
     *
     * @param callable $functionCallback A comparison callback compatible with {@link usort}.
     * @param string $columnSortedBy The column name `$functionCallback` sorts by. This is stored
     *                               so we can determine how the DataTable was sorted in the future.
     * @return void
     */
    public function sort($functionCallback, $columnSortedBy)
    {
        $this->setTableSortedBy($columnSortedBy);

        usort($this->rows, $functionCallback);

        if ($this->isSortRecursiveEnabled()) {
            foreach ($this->getRowsWithoutSummaryRow() as $row) {
                $subTable = $row->getSubtable();
                if ($subTable) {
                    $subTable->enableRecursiveSort();
                    $subTable->sort($functionCallback, $columnSortedBy);
                }
            }
        }
    }

    /**
     * @return void
     */
    public function setTotalsRow(Row $totalsRow)
    {
        $this->totalsRow = $totalsRow;
    }

    /**
     * @return Row|null
     */
    public function getTotalsRow()
    {
        return $this->totalsRow;
    }

    /**
     * @return Row|null
     */
    public function getSummaryRow()
    {
        return $this->summaryRow;
    }

    /**
     * Returns the name of the column this table was sorted by (if any).
     *
     * See {@link sort()}.
     *
     * @return false|string The sorted column name or false if none.
     */
    public function getSortedByColumnName()
    {
        return $this->tableSortedBy;
    }

    /**
     * Enables recursive sorting. If this method is called {@link sort()} will also sort all
     * subtables.
     * @return void
     */
    public function enableRecursiveSort()
    {
        $this->enableRecursiveSort = true;
    }

    /**
     * @return bool
     * @ignore
     */
    public function isSortRecursiveEnabled()
    {
        return $this->enableRecursiveSort === true;
    }

    /**
     * @param string $column
     * @return void
     * @ignore
     */
    public function setTableSortedBy($column)
    {
        $this->indexNotUpToDate = true;
        $this->tableSortedBy = $column;
    }

    /**
     * Enables recursive filtering. If this method is called then the {@link filter()} method
     * will apply filters to every subtable in addition to this instance.
     * @return void
     */
    public function enableRecursiveFilters()
    {
        $this->enableRecursiveFilters = true;
    }

    /**
     * @return void
     * @ignore
     */
    public function disableRecursiveFilters()
    {
        $this->enableRecursiveFilters = false;
    }

    /**
     * Applies a filter to this datatable.
     *
     * If {@link enableRecursiveFilters()} was called, the filter will be applied
     * to all subtables as well.
     *
     * @param string|callable $className Class name, eg. `"Sort"` or "Piwik\DataTable\Filters\Sort"`. If no
     *                                  namespace is supplied, `Piwik\DataTable\Filter` is assumed. This parameter
     *                                  can also be a closure that takes a DataTable as its first parameter.
     * @param array $parameters Array of extra parameters to pass to the filter.
     * @return void
     */
    public function filter($className, $parameters = array())
    {
        if (
            $className instanceof \Closure
            || is_array($className)
        ) {
            array_unshift($parameters, $this);
            call_user_func_array($className, $parameters);
            return;
        }

        if (!is_string($className)) {
            throw new Exception('Unsupported filter provided');
        }

        if (in_array($className, $this->disabledFilters)) {
            return;
        }

        if (!class_exists($className, true)) {
            $className = 'Piwik\DataTable\Filter\\' . $className;
        }
        $reflectionObj = new ReflectionClass($className);

        // the first parameter of a filter is the DataTable
        // we add the current datatable as the parameter
        $parameters = array_merge(array($this), $parameters);

        /** @var BaseFilter $filter */
        $filter = $reflectionObj->newInstanceArgs($parameters);

        $filter->enableRecursive($this->enableRecursiveFilters);

        $filter->filter($this);
    }

    /**
     * Invokes `$filter` with this table and every table in `$otherTables`. The result of `$filter()` is returned.
     *
     * This method is used to iterate over multiple DataTable\Map's concurrently.
     *
     * See {@link Map::multiFilter()} for more information.
     *
     * @param DataTable[] $otherTables
     * @param callable $filter A function like `function (DataTable $thisTable, $otherTable1, $otherTable2) {}`.
     * @return mixed The result of $filter.
     */
    public function multiFilter($otherTables, $filter)
    {
        return $filter(...array_merge([$this], $otherTables));
    }

    /**
     * Applies a filter to all subtables but not to this datatable.
     *
     * @param string|callable $className Class name, eg. `"Sort"` or "Piwik\DataTable\Filters\Sort"`. If no
     *                                  namespace is supplied, `Piwik\DataTable\BaseFilter` is assumed. This parameter
     *                                  can also be a closure that takes a DataTable as its first parameter.
     * @param array $parameters Array of extra parameters to pass to the filter.
     * @return void
     */
    public function filterSubtables($className, $parameters = array())
    {
        foreach ($this->getRowsWithoutSummaryRow() as $row) {
            $subtable = $row->getSubtable();
            if ($subtable) {
                $subtable->filter($className, $parameters);
                $subtable->filterSubtables($className, $parameters);
            }
        }
    }

    /**
     * Adds a filter and a list of parameters to the list of queued filters of all subtables. These filters will be
     * executed when {@link applyQueuedFilters()} is called.
     *
     * Filters that prettify the column values or don't need the full set of rows should be queued. This
     * way they will be run after the table is truncated which will result in better performance.
     *
     * @param string|callable $className The class name of the filter, eg. `'Limit'`.
     * @param array $parameters The parameters to give to the filter, eg. `array($offset, $limit)` for the Limit filter.
     * @return void
     */
    public function queueFilterSubtables($className, $parameters = array())
    {
        foreach ($this->getRowsWithoutSummaryRow() as $row) {
            $subtable = $row->getSubtable();
            if ($subtable) {
                $subtable->queueFilter($className, $parameters);
                $subtable->queueFilterSubtables($className, $parameters);
            }
        }
    }

    /**
     * Adds a filter and a list of parameters to the list of queued filters. These filters will be
     * executed when {@link applyQueuedFilters()} is called.
     *
     * Filters that prettify the column values or don't need the full set of rows should be queued. This
     * way they will be run after the table is truncated which will result in better performance.
     *
     * @param string|callable $className The class name of the filter, eg. `'Limit'`.
     * @param array $parameters The parameters to give to the filter, eg. `array($offset, $limit)` for the Limit filter.
     * @return void
     */
    public function queueFilter($className, $parameters = array())
    {
        if (!is_array($parameters)) {
            $parameters = array($parameters);
        }
        $this->queuedFilters[] = ['className' => $className, 'parameters' => $parameters];
    }

    /**
     * Disable a specific filter to run on this DataTable in case you have already applied this filter or if you will
     * handle this filter manually by using a custom filter. Be aware if you disable a given filter, that filter won't
     * be ever executed. Even if another filter calls this filter on the DataTable.
     *
     * @param string $className  eg 'Limit' or 'Sort'. Passing a `Closure` or an `array($class, $methodName)` is not
     *                           supported yet. We check for exact match. So if you disable 'Limit' and
     *                           call `->filter('Limit')` this filter won't be executed. If you call
     *                           `->filter('Piwik\DataTable\Filter\Limit')` that filter will be executed. See it as a
     *                           feature.
     * @return void
     * @ignore
     */
    public function disableFilter($className)
    {
        $this->disabledFilters[] = $className;
    }

    /**
     * Applies all filters that were previously queued to the table. See {@link queueFilter()}
     * for more information.
     * @return void
     */
    public function applyQueuedFilters()
    {
        foreach ($this->queuedFilters as $filter) {
            $this->filter($filter['className'], $filter['parameters']);
        }
        $this->clearQueuedFilters();
    }

    /**
     * Sums a DataTable to this one.
     *
     * This method will sum rows that have the same label. If a row is found in `$tableToSum` whose
     * label is not found in `$this`, the row will be added to `$this`.
     *
     * If the subtables for this table are loaded, they will be summed as well.
     *
     * Rows are summed together by summing individual columns. By default columns are summed by
     * adding one column value to another. Some columns cannot be aggregated this way. In these
     * cases, the {@link COLUMN_AGGREGATION_OPS_METADATA_NAME}
     * metadata can be used to specify a different type of operation.
     *
     * @return void
     * @throws Exception
     */
    public function addDataTable(DataTable $tableToSum)
    {
        if ($tableToSum instanceof Simple) {
            if ($tableToSum->getRowsCount() > 1) {
                throw new Exception("Did not expect a Simple table with more than one row in addDataTable()");
            }
            $row = $tableToSum->getFirstRow();
            $this->aggregateRowFromSimpleTable($row);
        } else {
            $columnAggregationOps = $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME);
            foreach ($tableToSum->getRowsWithoutSummaryRow() as $row) {
                $this->aggregateRowWithLabel($row, $columnAggregationOps);
            }
            // we do not use getRows() as this method might get called 100k times when aggregating many datatables and
            // this takes a lot of time.
            $row = $tableToSum->getRowFromId(DataTable::ID_SUMMARY_ROW);
            if ($row) {
                $this->aggregateRow($this->summaryRow, $row, $columnAggregationOps, true);
            }
        }
    }

    /**
     * Returns the Row whose `'label'` column is equal to `$label`.
     *
     * This method executes in constant time except for the first call which caches row
     * label => row ID mappings.
     *
     * @param string $label `'label'` column value to look for.
     * @return Row|false The row if found, `false` if otherwise.
     */
    public function getRowFromLabel($label)
    {
        $rowId = $this->getRowIdFromLabel($label);
        if (is_int($rowId) && isset($this->rows[$rowId])) {
            return $this->rows[$rowId];
        }
        if (
            $rowId == self::ID_SUMMARY_ROW
            && !empty($this->summaryRow)
        ) {
            return $this->summaryRow;
        }
        if (
            empty($rowId)
            && !empty($this->totalsRow)
            && $label == $this->totalsRow->getColumn('label')
        ) {
            return $this->totalsRow;
        }
        return false;
    }

    /**
     * Returns the row id for the row whose `'label'` column is equal to `$label`.
     *
     * This method executes in constant time except for the first call which caches row
     * label => row ID mappings.
     *
     * @param string $label `'label'` column value to look for.
     * @return int|false The row ID.
     */
    public function getRowIdFromLabel($label)
    {
        if ($this->indexNotUpToDate) {
            $this->rebuildIndex();
        }

        $label = (string) $label;

        if (!isset($this->rowsIndexByLabel[$label])) {
            // in case label is '-1' and there is no normal row w/ that label. Note: this is for BC since
            // in the past, it was possible to get the summary row by searching for the label '-1'
            if (
                $label == self::LABEL_SUMMARY_ROW
                && !is_null($this->summaryRow)
            ) {
                return self::ID_SUMMARY_ROW;
            }

            return false;
        }

        return $this->rowsIndexByLabel[$label];
    }

    /**
     * Returns an empty DataTable with the same metadata and queued filters as `$this` one.
     *
     * @param bool $keepFilters Whether to pass the queued filter list to the new DataTable or not.
     * @return DataTable
     */
    public function getEmptyClone($keepFilters = true)
    {
        $clone = new DataTable();
        if ($keepFilters) {
            $clone->queuedFilters = $this->queuedFilters;
        }
        $clone->metadata = $this->metadata;
        return $clone;
    }

    /**
     * Rebuilds the index used to lookup a row by label
     * @return void
     * @internal
     */
    public function rebuildIndex()
    {
        $this->rowsIndexByLabel = [];
        $this->rebuildIndexContinuously = true;

        foreach ($this->rows as $id => $row) {
            $label = $row->getColumn('label');
            if ($label !== false) {
                $this->rowsIndexByLabel[(string) $label] = $id;
            }
        }

        $this->indexNotUpToDate = false;
    }

    /**
     * Returns a row by ID. The ID is either the index of the row or {@link ID_SUMMARY_ROW}.
     *
     * @param int $id The row ID.
     * @return Row|false The Row or false if not found.
     */
    public function getRowFromId($id)
    {
        if (
            $id == self::ID_SUMMARY_ROW
            && !is_null($this->summaryRow)
        ) {
            return $this->summaryRow;
        }

        if (!isset($this->rows[$id])) {
            return false;
        }
        return $this->rows[$id];
    }

    /**
     * Returns the row that has a subtable with ID matching `$idSubtable`.
     *
     * @param int $idSubTable The subtable ID.
     * @return Row|false The row or false if not found
     */
    public function getRowFromIdSubDataTable($idSubTable)
    {
        $idSubTable = (int)$idSubTable;
        foreach ($this->rows as $row) {
            if ($row->getIdSubDataTable() === $idSubTable) {
                return $row;
            }
        }
        return false;
    }

    /**
     * Adds a row to this table.
     *
     * If {@link setMaximumAllowedRows()} was called and the current row count is
     * at the maximum, the new row will be summed to the summary row. If there is no summary row,
     * this row is set as the summary row.
     *
     * @return Row `$row` or the summary row if we're at the maximum number of rows.
     */
    public function addRow(Row $row)
    {
        // if there is a upper limit on the number of allowed rows and the table is full,
        // add the new row to the summary row
        if (
            $this->maximumAllowedRows > 0
            && $this->getRowsCount() >= $this->maximumAllowedRows - 1
        ) {
            if ($this->summaryRow === null) {
                // create the summary row if necessary

                $columns = array('label' => self::LABEL_SUMMARY_ROW) + $row->getColumns();
                $this->addSummaryRow(new Row(array(Row::COLUMNS => $columns)));
            } else {
                $this->summaryRow->sumRow(
                    $row,
                    $enableCopyMetadata = false,
                    $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME)
                );
            }
            return $this->summaryRow;
        }

        $this->rows[] = $row;
        if (
            !$this->indexNotUpToDate
            && $this->rebuildIndexContinuously
        ) {
            $label = $row->getColumn('label');
            if ($label !== false) {
                $this->rowsIndexByLabel[(string) $label] = count($this->rows) - 1;
            }
        }
        return $row;
    }

    /**
     * Sets the summary row.
     *
     * _Note: A DataTable can have only one summary row._
     *
     * @return Row Returns `$row`.
     */
    public function addSummaryRow(Row $row)
    {
        $this->summaryRow = $row;
        $row->setIsSummaryRow();

        // NOTE: the summary row does not go in the index, since it will overwrite rows w/ label == -1

        return $row;
    }

    /**
     * Returns the DataTable ID.
     *
     * @return int
     */
    public function getId()
    {
        return $this->currentId;
    }

    /**
     * Adds a new row from an array.
     *
     * You can add row metadata with this method.
     *
     * @param array $row eg. `array(Row::COLUMNS => array('visits' => 13, 'test' => 'toto'),
     *                              Row::METADATA => array('mymetadata' => 'myvalue'))`
     * @return void
     */
    public function addRowFromArray($row)
    {
        $this->addRowsFromArray(array($row));
    }

    /**
     * Adds a new row a from an array of column values.
     *
     * Row metadata cannot be added with this method.
     *
     * @param array $row eg. `array('name' => 'google analytics', 'license' => 'commercial')`
     * @return void
     */
    public function addRowFromSimpleArray($row)
    {
        $this->addRowsFromSimpleArray(array($row));
    }

    /**
     * Returns the array of Rows.
     * Internal logic in Matomo core should avoid using this method as it is time and memory consuming when being
     * executed thousands of times. The alternative is to use {@link getRowsWithoutSummaryRow()} + get the summary
     * row manually.
     *
     * @return Row[]
     */
    public function getRows()
    {
        if (is_null($this->summaryRow)) {
            return $this->rows;
        } else {
            return $this->rows + array(self::ID_SUMMARY_ROW => $this->summaryRow);
        }
    }

    /**
     * @return Row[]
     * @ignore
     */
    public function getRowsWithoutSummaryRow()
    {
        return $this->rows;
    }

    /**
     * Removes and returns the first non-summary row in this table.
     *
     * @return Row|null
     */
    public function shiftRow()
    {
        if (empty($this->rows)) {
            return null;
        }

        reset($this->rows);
        $rowId = key($this->rows);

        if (!isset($this->rows[$rowId])) {
            return null;
        }

        $row = $this->rows[$rowId];
        unset($this->rows[$rowId]);

        return $row;
    }

    /**
     * @return int
     * @ignore
     */
    public function getRowsCountWithoutSummaryRow()
    {
        return count($this->rows);
    }

    /**
     * Returns an array containing all column values for the requested column.
     *
     * @param string|int $name The column name.
     * @return array The array of column values.
     */
    public function getColumn($name)
    {
        $columnValues = array();
        foreach ($this->getRows() as $row) {
            $columnValues[] = $row->getColumn($name);
        }
        return $columnValues;
    }

    /**
     * Returns an array containing all column values of columns whose name starts with `$name`.
     *
     * @param string $namePrefix The column name prefix.
     * @return array The array of column values.
     */
    public function getColumnsStartingWith($namePrefix)
    {
        $columnValues = array();
        foreach ($this->getRows() as $row) {
            $columns = $row->getColumns();
            foreach ($columns as $column => $value) {
                if (strpos($column, $namePrefix) === 0) {
                    $columnValues[] = $row->getColumn($column);
                }
            }
        }
        return $columnValues;
    }

    /**
     * Returns the names of every column this DataTable contains. This method will return the
     * columns of the first row with data and will assume they occur in every other row as well.
     *
     *_ Note: If column names still use their in-database INDEX values (@see Metrics), they
     *        will be converted to their string name in the array result._
     *
     * @return array Array of string column names.
     */
    public function getColumns()
    {
        $result = array();
        foreach ($this->getRows() as $row) {
            $columns = $row->getColumns();
            if (!empty($columns)) {
                $result = array_keys($columns);
                break;
            }
        }

        // make sure column names are not DB index values
        foreach ($result as &$column) {
            if (isset(Metrics::$mappingFromIdToName[$column])) {
                $column = Metrics::$mappingFromIdToName[$column];
            }
        }

        return $result;
    }

    /**
     * Returns an array containing the requested metadata value of each row.
     *
     * @param string $name The metadata column to return.
     * @return array
     */
    public function getRowsMetadata($name)
    {
        $metadataValues = array();
        foreach ($this->getRows() as $row) {
            $metadataValues[] = $row->getMetadata($name);
        }
        return $metadataValues;
    }

    /**
     * Delete row metadata by name in every row.
     *
     * @param string $name
     * @param bool $deleteRecursiveInSubtables
     * @return void
     */
    public function deleteRowsMetadata($name, $deleteRecursiveInSubtables = false)
    {
        foreach ($this->rows as $row) {
            $row->deleteMetadata($name);

            $subTable = $row->getSubtable();
            if ($subTable) {
                $subTable->deleteRowsMetadata($name, $deleteRecursiveInSubtables);
            }
        }
        if (!is_null($this->summaryRow)) {
            $this->summaryRow->deleteMetadata($name);
        }
        if (!is_null($this->totalsRow)) {
            $this->totalsRow->deleteMetadata($name);
        }
    }

    /**
     * Returns the number of rows in the table including the summary row.
     *
     * @return int
     */
    public function getRowsCount()
    {
        if (is_null($this->summaryRow)) {
            return count($this->rows);
        } else {
            return count($this->rows) + 1;
        }
    }

    /**
     * Returns the first row of the DataTable.
     *
     * @return Row|false The first row or `false` if it cannot be found.
     */
    public function getFirstRow()
    {
        if (count($this->rows) == 0) {
            if (!is_null($this->summaryRow)) {
                return $this->summaryRow;
            }
            return false;
        }
        return reset($this->rows);
    }

    /**
     * Returns the last row of the DataTable. If there is a summary row, it
     * will always be considered the last row.
     *
     * @return Row|false The last row or `false` if it cannot be found.
     */
    public function getLastRow()
    {
        if (!is_null($this->summaryRow)) {
            return $this->summaryRow;
        }

        if (count($this->rows) == 0) {
            return false;
        }

        return end($this->rows);
    }

    /**
     * Returns the number of rows in the entire DataTable hierarchy. This is the number of rows in this DataTable
     * summed with the row count of each descendant subtable.
     *
     * @return int
     */
    public function getRowsCountRecursive()
    {
        $totalCount = 0;
        foreach ($this->rows as $row) {
            $subTable = $row->getSubtable();
            if ($subTable) {
                $count = $subTable->getRowsCountRecursive();
                $totalCount += $count;
            }
        }

        $totalCount += $this->getRowsCount();
        return $totalCount;
    }

    /**
     * Returns the number of leaf rows in the entire DataTable hierarchy. Only rows that do not contain a subtables are counted
     *
     * @return int
     */
    public function getLeafRowsCount()
    {
        $totalCount = 0;
        foreach ($this->rows as $row) {
            $subTable = $row->getSubtable();
            if ($subTable) {
                $totalCount += $subTable->getLeafRowsCount();
            } else {
                $totalCount++;
            }
        }

        return $totalCount;
    }

    /**
     * Delete a column by name in every row. This change is NOT applied recursively to all
     * subtables.
     *
     * @param string|int $name Column name to delete.
     * @return void
     */
    public function deleteColumn($name)
    {
        $this->deleteColumns(array($name));
    }

    public function __sleep()
    {
        return array('rows', 'summaryRow', 'metadata', 'totalsRow');
    }

    /**
     * Rename a column in every row. This change is applied recursively to all subtables.
     *
     * @param string $oldName Old column name.
     * @param string $newName New column name.
     * @return void
     */
    public function renameColumn($oldName, $newName)
    {
        foreach ($this->rows as $row) {
            $row->renameColumn($oldName, $newName);

            $subTable = $row->getSubtable();
            if ($subTable) {
                $subTable->renameColumn($oldName, $newName);
            }
        }
        if (!is_null($this->summaryRow)) {
            $this->summaryRow->renameColumn($oldName, $newName);
        }
        if (!is_null($this->totalsRow)) {
            $this->totalsRow->renameColumn($oldName, $newName);
        }
    }

    /**
     * Deletes several columns by name in every row.
     *
     * @param list<string|int> $names List of column names to delete.
     * @param bool $deleteRecursiveInSubtables Whether to apply this change to all subtables or not.
     * @return void
     */
    public function deleteColumns($names, $deleteRecursiveInSubtables = false)
    {
        foreach ($this->rows as $row) {
            foreach ($names as $name) {
                $row->deleteColumn($name);
            }
            $subTable = $row->getSubtable();
            if ($subTable) {
                $subTable->deleteColumns($names, $deleteRecursiveInSubtables);
            }
        }
        if (!is_null($this->summaryRow)) {
            foreach ($names as $name) {
                $this->summaryRow->deleteColumn($name);
            }
        }
        if (!is_null($this->totalsRow)) {
            foreach ($names as $name) {
                $this->totalsRow->deleteColumn($name);
            }
        }
    }

    /**
     * Deletes a row by ID.
     *
     * @param int $id The row ID.
     * @return void
     * @throws Exception If the row `$id` cannot be found.
     */
    public function deleteRow($id)
    {
        if ($id === self::ID_SUMMARY_ROW) {
            $this->summaryRow = null;
            return;
        }
        if (!isset($this->rows[$id])) {
            throw new Exception("Trying to delete unknown row with idkey = $id");
        }
        unset($this->rows[$id]);
    }

    /**
     * Deletes rows from `$offset` to `$offset + $limit`.
     *
     * @param int $offset The offset to start deleting rows from.
     * @param int|null $limit The number of rows to delete. If `null` all rows after the offset
     *                        will be removed.
     * @return int The number of rows deleted.
     */
    public function deleteRowsOffset($offset, $limit = null)
    {
        if ($limit === 0) {
            return 0;
        }

        $count = $this->getRowsCount();
        if ($offset >= $count) {
            return 0;
        }

        // if we delete until the end, we delete the summary row as well
        if (
            is_null($limit)
            || $limit >= $count
        ) {
            $this->summaryRow = null;
        }

        if (is_null($limit)) {
            array_splice($this->rows, $offset);
        } else {
            array_splice($this->rows, $offset, $limit);
        }

        return $count - $this->getRowsCount();
    }

    /**
     * Deletes a set of rows by ID.
     *
     * @param array $rowIds The list of row IDs to delete.
     * @return void
     * @throws Exception If a row ID cannot be found.
     */
    public function deleteRows(array $rowIds)
    {
        foreach ($rowIds as $key) {
            $this->deleteRow($key);
        }
    }

    /**
     * Returns a string representation of this DataTable for convenient viewing.
     *
     * _Note: This uses the **html** DataTable renderer._
     *
     * @return string
     */
    public function __toString()
    {
        $renderer = new Html();
        $renderer->setTable($this);
        return (string)$renderer;
    }

    /**
     * Returns true if both DataTable instances are exactly the same.
     *
     * DataTables are equal if they have the same number of rows, if
     * each row has a label that exists in the other table, and if each row
     * is equal to the row in the other table with the same label. The order
     * of rows is not important.
     *
     * @return bool
     */
    public static function isEqual(DataTable $table1, DataTable $table2)
    {
        $table1->rebuildIndex();
        $table2->rebuildIndex();

        if ($table1->getRowsCount() != $table2->getRowsCount()) {
            return false;
        }

        $rows1 = $table1->getRows();

        foreach ($rows1 as $row1) {
            $row2 = $table2->getRowFromLabel($row1->getColumn('label'));
            if (
                $row2 === false
                || !Row::isEqual($row1, $row2)
            ) {
                return false;
            }
        }

        return true;
    }

    /**
     * Serializes an entire DataTable hierarchy and returns the array of serialized DataTables.
     *
     * The first element in the returned array will be the serialized representation of this DataTable.
     * Every subsequent element will be a serialized subtable.
     *
     * This DataTable and subtables can optionally be truncated before being serialized. In most
     * cases where DataTables can become quite large, they should be truncated before being persisted
     * in an archive.
     *
     * The result of this method is intended for use with the {@link ArchiveProcessor::insertBlobRecord()} method.
     *
     * @throws Exception If infinite recursion detected. This will occur if a table's subtable is one of its parent tables.
     * @param int $maximumRowsInDataTable If not null, defines the maximum number of rows allowed in the serialized DataTable.
     * @param int $maximumRowsInSubDataTable If not null, defines the maximum number of rows allowed in serialized subtables.
     * @param string $columnToSortByBeforeTruncation The column to sort by before truncating, eg, `Metrics::INDEX_NB_VISITS`.
     * @param array $aSerializedDataTable Will contain all the output arrays
     * @return array The array of serialized DataTables:
     *
     *                   array(
     *                       // this DataTable (the root)
     *                       0 => 'eghuighahgaueytae78yaet7yaetae',
     *
     *                       // a subtable
     *                       1 => 'gaegae gh gwrh guiwh uigwhuige',
     *
     *                       // another subtable
     *                       2 => 'gqegJHUIGHEQjkgneqjgnqeugUGEQHGUHQE',
     *
     *                       // etc.
     *                   );
     */
    public function getSerialized(
        $maximumRowsInDataTable = null,
        $maximumRowsInSubDataTable = null,
        $columnToSortByBeforeTruncation = null,
        &$aSerializedDataTable = array()
    ) {
        static $depth = 0;
        // make sure subtableIds are consecutive from 1 to N
        static $subtableId = 0;

        if ($depth > self::$maximumDepthLevelAllowed) {
            $depth = 0;
            $subtableId = 0;
            throw new Exception("Maximum recursion level of " . self::$maximumDepthLevelAllowed . " reached. Maybe you have set a DataTable\Row with an associated DataTable belonging already to one of its parent tables?");
        }

        // gather metadata before filters are called, so their metadata is not stored in serialized form
        $metadata = $this->getAllTableMetadata();
        foreach ($metadata as $key => $value) {
            if (!is_scalar($value)) {
                unset($metadata[$key]);
            }
        }

        if (!is_null($maximumRowsInDataTable)) {
            $this->filter(
                'Truncate',
                array($maximumRowsInDataTable - 1,
                      DataTable::LABEL_SUMMARY_ROW,
                      $columnToSortByBeforeTruncation,
                      $filterRecursive = false)
            );
        }

        $consecutiveSubtableIds = array();
        $forcedId = $subtableId;

        // For each row (including the summary row), get the serialized row
        // If it is associated to a sub table, get the serialized table recursively ;
        // but returns all serialized tables and subtable in an array of 1 dimension
        foreach ($this->getRows() as $id => $row) {
            $subTable = $row->getSubtable();
            if ($subTable) {
                $consecutiveSubtableIds[$id] = ++$subtableId;
                $depth++;
                $subTable->getSerialized($maximumRowsInSubDataTable, $maximumRowsInSubDataTable, $columnToSortByBeforeTruncation, $aSerializedDataTable);
                $depth--;
            } else {
                $row->removeSubtable();
            }
        }

        // if the datatable is the parent we force the Id at 0 (this is part of the specification)
        if ($depth == 0) {
            $forcedId = 0;
            $subtableId = 0;
        }

        // we then serialize the rows and store them in the serialized dataTable
        $rows = array();
        foreach ($this->rows as $id => $row) {
            if (isset($consecutiveSubtableIds[$id])) {
                $backup = $row->subtableId;
                $row->subtableId = $consecutiveSubtableIds[$id];
                $rows[$id] = $row->export();
                $row->subtableId = $backup;
            } else {
                $rows[$id] = $row->export();
            }
        }

        if (isset($this->summaryRow)) {
            $id = self::ID_SUMMARY_ROW;
            $row = $this->summaryRow;

            // duplicating code above so we don't create a new array w/ getRows() above in this function which is
            // used heavily in matomo.
            if (isset($consecutiveSubtableIds[$id])) {
                $backup = $row->subtableId;
                $row->subtableId = $consecutiveSubtableIds[$id];
                $rows[$id] = $row->export();
                $row->subtableId = $backup;
            } else {
                $rows[$id] = $row->export();
            }
        }

        if (!empty($metadata)) {
            $metadataRow = new Row();
            $metadataRow->setColumns($metadata);

            // set the label so the row will be indexed correctly internally
            $metadataRow->setColumn('label', self::LABEL_ARCHIVED_METADATA_ROW);

            $rows[self::ID_ARCHIVED_METADATA_ROW] = $metadataRow->export();
        }

        $aSerializedDataTable[$forcedId] = serialize($rows);
        unset($rows);

        return $aSerializedDataTable;
    }

    /** @var string[] */
    private static $previousRowClasses = [
        'O:39:"Piwik\DataTable\Row\DataTableSummaryRow"',
        'O:19:"Piwik\DataTable\Row"',
        'O:36:"Piwik_DataTable_Row_DataTableSummary"',
        'O:19:"Piwik_DataTable_Row"',
    ];

    /** @var string */
    private static $rowClassToUseForUnserialize = 'O:29:"Piwik_DataTable_SerializedRow"';

    /**
     * It is faster to unserialize existing serialized Row instances to "Piwik_DataTable_SerializedRow" and access the
     * `$row->c` property than implementing a "__wakeup" method in the Row instance to map the "$row->c" to $row->columns
     * etc. We're talking here about 15% faster reports aggregation in some cases. To be concrete: We have a test where
     * Archiving a year takes 1700 seconds with "__wakeup" and 1400 seconds with this method. Yes, it takes 300 seconds
     * to wake up millions of rows. We should be able to remove this code here end 2015 and use the "__wakeup" way by then.
     * Why? By then most new archives will have only arrays serialized anyway and therefore this mapping is rather an overhead.
     *
     * @param string $serialized
     * @return array
     * @throws Exception In case the unserialize fails
     */
    private function unserializeRows($serialized)
    {
        // Current archives only persist row arrays, so do not allow objects in the default path.
        $rows = Common::safe_unserialize($serialized, []);

        if (!$this->isValidRowsPayload($rows, $allowLegacySerializedRowObjects = false)) {
            $rows = false;
        }

        if ($rows === false) {
            // Legacy object payloads are attempted as a fallback for BC.
            $legacySerialized = str_replace(
                array_map(function ($class) {
                    return $class . ':';
                }, self::$previousRowClasses),
                self::$rowClassToUseForUnserialize . ':',
                $serialized
            );
            $rows = Common::safe_unserialize($legacySerialized, [
                \Piwik_DataTable_SerializedRow::class,
            ]);
        }

        if (!$this->isValidRowsPayload($rows, $allowLegacySerializedRowObjects = true)) {
            throw new Exception("The unserialization has failed!");
        }

        return $rows;
    }

    private function isValidRowsPayload($rows, bool $allowLegacySerializedRowObjects): bool
    {
        if (!is_array($rows)) {
            return false;
        }

        foreach ($rows as $row) {
            if ($allowLegacySerializedRowObjects && $this->isValidLegacySerializedRowObject($row)) {
                continue;
            }

            if ($this->containsObject($row)) {
                return false;
            }
        }

        return true;
    }

    private function isValidLegacySerializedRowObject($row): bool
    {
        if (!$row instanceof \Piwik_DataTable_SerializedRow) {
            return false;
        }

        return isset($row->c) && is_array($row->c) && !$this->containsObject($row->c);
    }

    private function containsObject($value): bool
    {
        if (is_object($value)) {
            return true;
        }

        if (!is_array($value)) {
            return false;
        }

        $containsObject = false;

        try {
            array_walk_recursive($value, function ($entry) use (&$containsObject): void {
                if (is_object($entry)) {
                    $containsObject = true;
                }
            });
        } catch (\Throwable $error) {
            throw new Exception('The unserialization has failed! Array payload cannot be safely traversed.', 0, $error);
        }

        return $containsObject;
    }

    /**
     * Adds a set of rows from a serialized DataTable string.
     *
     * See {@link serialize()}.
     *
     * _Note: This function will successfully load DataTables serialized by Piwik 1.X._
     *
     * @param string $serialized A string with the format of a string in the array returned by
     *                          {@link serialize()}.
     * @return void
     * @throws Exception if `$serialized` is invalid.
     */
    public function addRowsFromSerializedArray($serialized)
    {
        $rows = $this->unserializeRows($serialized);

        if (array_key_exists(self::ID_SUMMARY_ROW, $rows)) {
            if (is_array($rows[self::ID_SUMMARY_ROW])) {
                $this->summaryRow = new Row($rows[self::ID_SUMMARY_ROW]);
                $this->summaryRow->setIsSummaryRow();
            } elseif (isset($rows[self::ID_SUMMARY_ROW]->c)) {
                $this->summaryRow = new Row($rows[self::ID_SUMMARY_ROW]->c); // Pre Piwik 2.13
                $this->summaryRow->setIsSummaryRow();
            }
            unset($rows[self::ID_SUMMARY_ROW]);
        }

        if (array_key_exists(self::ID_ARCHIVED_METADATA_ROW, $rows)) {
            $metadata = $rows[self::ID_ARCHIVED_METADATA_ROW][Row::COLUMNS];
            unset($metadata['label']);
            $this->setAllTableMetadata($metadata);
            unset($rows[self::ID_ARCHIVED_METADATA_ROW]);
        }

        foreach ($rows as $id => $row) {
            if (isset($row->c)) {
                $this->addRow(new Row($row->c)); // Pre Piwik 2.13
            } else {
                $this->addRow(new Row($row));
            }
        }
    }

    /**
     * Adds multiple rows from an array.
     *
     * You can add row metadata with this method.
     *
     * @param array $array Array with the following structure
     *
     *                         array(
     *                             // row1
     *                             array(
     *                                 Row::COLUMNS => array( col1_name => value1, col2_name => value2, ...),
     *                                 Row::METADATA => array( metadata1_name => value1,  ...), // see Row
     *                             ),
     *                             // row2
     *                             array( ... ),
     *                         )
     * @return void
     */
    public function addRowsFromArray($array)
    {
        foreach ($array as $id => $row) {
            if (is_array($row)) {
                $row = new Row($row);
            }

            if ($id == self::ID_SUMMARY_ROW) {
                $this->summaryRow = $row;
                $this->summaryRow->setIsSummaryRow();
            } else {
                $this->addRow($row);
            }
        }
    }

    /**
     * Adds multiple rows from an array containing arrays of column values.
     *
     * Row metadata cannot be added with this method.
     *
     * @param array $array Array with the following structure:
     *
     *                       array(
     *                             array( col1_name => valueA, col2_name => valueC, ...),
     *                             array( col1_name => valueB, col2_name => valueD, ...),
     *                       )
     * @return void
     * @throws Exception if `$array` is in an incorrect format.
     */
    public function addRowsFromSimpleArray($array)
    {
        if (count($array) === 0) {
            return;
        }

        $exceptionText = "Data structure returned is not convertible in the requested format: %s" .
            " Try to call this method with the parameters '&format=original&serialize=1'" .
            "; you will get the original php data structure serialized.";

        // first pass to see if the array has the structure
        // array(col1_name => val1, col2_name => val2, etc.)
        // with val* that are never arrays (only strings/numbers/bool/etc.)
        // if we detect such a "simple" data structure we convert it to a row with the correct columns' names
        $thisIsNotThatSimple = false;

        foreach ($array as $columnValue) {
            if (is_array($columnValue) || is_object($columnValue)) {
                $thisIsNotThatSimple = true;
                break;
            }
        }
        if ($thisIsNotThatSimple === false) {
            // case when the array is indexed by the default numeric index
            if (array_keys($array) === array_keys(array_fill(0, count($array), true))) {
                foreach ($array as $row) {
                    $this->addRow(new Row(array(Row::COLUMNS => array($row))));
                }
            } else {
                $this->addRow(new Row(array(Row::COLUMNS => $array)));
            }
            // we have converted our simple array to one single row
            // => we exit the method as the job is now finished
            return;
        }

        foreach ($array as $key => $row) {
            // stuff that looks like a line
            if (is_array($row)) {
                /**
                 * We make sure we can convert this PHP array without losing information.
                 * We are able to convert only simple php array (no strings keys, no sub arrays, etc.)
                 *
                 */

                // if the key is a string it means that some information was contained in this key.
                // it cannot be lost during the conversion. Because we are not able to handle properly
                // this key, we throw an explicit exception.
                if (is_string($key)) {
                    // we define an exception we may throw if at one point we notice that we cannot handle the data structure
                    throw new Exception(
                        sprintf(
                            $exceptionText,
                            sprintf(
                                "Only integer keys supported for array columns on base level. Unsupported string '%s' found for row '%s'.",
                                $key,
                                substr(var_export($row, true), 0, 500)
                            )
                        )
                    );
                }
                // if any of the sub elements of row is an array we cannot handle this data structure...
                foreach ($row as $name => $subRow) {
                    if (is_array($subRow)) {
                        throw new Exception(
                            sprintf(
                                $exceptionText,
                                sprintf(
                                    "Multidimensional column values not supported. Found unexpected array value for column '%s' in row '%s': '%s'.",
                                    $name,
                                    $key,
                                    substr(var_export($subRow, true), 0, 500)
                                )
                            )
                        );
                    }
                }
                $row = new Row(array(Row::COLUMNS => $row));
            } else {
                // other (string, numbers...) => we build a line from this value
                $row = new Row(array(Row::COLUMNS => array($key => $row)));
            }
            $this->addRow($row);
        }
    }

    /**
     * Rewrites the input `$array`
     *
     *     array (
     *         LABEL => array(col1 => X, col2 => Y),
     *         LABEL2 => array(col1 => X, col2 => Y),
     *     )
     *
     * to a DataTable with rows that look like:
     *
     *     array (
     *         array( Row::COLUMNS => array('label' => LABEL, col1 => X, col2 => Y)),
     *         array( Row::COLUMNS => array('label' => LABEL2, col1 => X, col2 => Y)),
     *     )
     *
     * Will also convert arrays like:
     *
     *     array (
     *         LABEL => X,
     *         LABEL2 => Y,
     *     )
     *
     * to:
     *
     *     array (
     *         array( Row::COLUMNS => array('label' => LABEL, 'value' => X)),
     *         array( Row::COLUMNS => array('label' => LABEL2, 'value' => Y)),
     *     )
     *
     * @param array $array Indexed array, two formats supported, see above.
     * @param array|null $subtablePerLabel An array mapping label values with DataTable instances to associate as a subtable.
     * @return DataTable
     */
    public static function makeFromIndexedArray($array, $subtablePerLabel = null)
    {
        $table = new DataTable();
        foreach ($array as $label => $row) {
            $cleanRow = array();

            // Support the case of an $array of single values
            if (!is_array($row)) {
                $row = array('value' => $row);
            }
            // Put the 'label' column first
            $cleanRow[Row::COLUMNS] = array('label' => $label) + $row;
            // Assign subtable if specified
            if (isset($subtablePerLabel[$label])) {
                $cleanRow[Row::DATATABLE_ASSOCIATED] = $subtablePerLabel[$label];
            }

            if ($label === RankingQuery::LABEL_SUMMARY_ROW) {
                $table->addSummaryRow(new Row($cleanRow));
            } else {
                $table->addRow(new Row($cleanRow));
            }
        }
        return $table;
    }

    /**
     * Sets the maximum depth level to at least a certain value. If the current value is
     * greater than `$atLeastLevel`, the maximum nesting level is not changed.
     *
     * The maximum depth level determines the maximum number of subtable levels in the
     * DataTable tree. For example, if it is set to `2`, this DataTable is allowed to
     * have subtables, but the subtables are not.
     *
     * @param int $atLeastLevel
     * @return void
     */
    public static function setMaximumDepthLevelAllowedAtLeast($atLeastLevel)
    {
        self::$maximumDepthLevelAllowed = max($atLeastLevel, self::$maximumDepthLevelAllowed);
        if (self::$maximumDepthLevelAllowed < 1) {
            self::$maximumDepthLevelAllowed = 1;
        }
    }

    /**
     * Returns metadata by name.
     *
     * @param string $name The metadata name.
     * @return mixed|false The metadata value or `false` if it cannot be found.
     */
    public function getMetadata($name)
    {
        if (!isset($this->metadata[$name])) {
            return false;
        }
        return $this->metadata[$name];
    }

    /**
     * Sets a metadata value by name.
     *
     * @param string $name The metadata name.
     * @param mixed $value
     * @return void
     */
    public function setMetadata($name, $value)
    {
        $this->metadata[$name] = $value;
    }

    /**
     * Deletes a metadata property by name.
     *
     * @param bool|string $name The metadata name (omit to delete all metadata)
     * @return bool True if the requested metadata was deleted
     */
    public function deleteMetadata($name = false): bool
    {
        if ($name === false) {
            $this->metadata = [];
            return true;
        }
        if (!isset($this->metadata[$name])) {
            return false;
        }
        unset($this->metadata[$name]);
        return true;
    }

    /**
     * Returns all table metadata.
     *
     * @return array<string, mixed>
     */
    public function getAllTableMetadata()
    {
        return $this->metadata;
    }

    /**
     * Sets several metadata values by name.
     *
     * @param array<string, mixed> $values Array mapping metadata names with metadata values.
     * @return void
     */
    public function setMetadataValues($values)
    {
        foreach ($values as $name => $value) {
            $this->metadata[$name] = $value;
        }
    }

    /**
     * Sets metadata, erasing existing values.
     *
     * @param array $metadata Array mapping metadata names with metadata values.
     * @return void
     */
    public function setAllTableMetadata($metadata)
    {
        $this->metadata = $metadata;
    }

    /**
     * Sets the maximum number of rows allowed in this datatable (including the summary
     * row). If adding more then the allowed number of rows is attempted, the extra
     * rows are summed to the summary row.
     *
     * @param int $maximumAllowedRows If `0`, the maximum number of rows is unset.
     * @return void
     */
    public function setMaximumAllowedRows($maximumAllowedRows)
    {
        $this->maximumAllowedRows = $maximumAllowedRows;
    }

    /**
     * Traverses a DataTable tree using an array of labels and returns the row
     * it finds or `false` if it cannot find one. The number of path segments that
     * were successfully walked is also returned.
     *
     * If `$missingRowColumns` is supplied, the specified path is created. When
     * a subtable is encountered w/o the required label, a new row is created
     * with the label, and a new subtable is added to the row.
     *
     * Read [https://en.wikipedia.org/wiki/Tree_(data_structure)#Traversal_methods](https://en.wikipedia.org/wiki/Tree_(data_structure)#Traversal_methods)
     * for more information about tree walking.
     *
     * @param array $path The path to walk. An array of label values. The first element
     *                    refers to a row in this DataTable, the second in a subtable of
     *                    the first row, the third a subtable of the second row, etc.
     * @param array|false $missingRowColumns The default columns to use when creating new rows.
     *                                      If this parameter is supplied, new rows will be
     *                                      created for path labels that cannot be found.
     * @param int $maxSubtableRows The maximum number of allowed rows in new subtables. New
     *                             subtables are only created if `$missingRowColumns` is provided.
     * @return array{0: false|Row, 1: int} First element is the found row or `false`. Second element is
     *                                     the number of path segments walked. If a row is found, this
     *                                     will be == to `count($path)`. Otherwise, it will be the index
     *                                     of the path segment that we could not find.
     */
    public function walkPath($path, $missingRowColumns = false, $maxSubtableRows = 0)
    {
        $pathLength = count($path);

        $table = $this;
        $next = false;
        for ($i = 0; $i < $pathLength; ++$i) {
            $segment = $path[$i];

            $next = $table->getRowFromLabel($segment);
            if ($next === false) {
                // if there is no table to advance to, and we're not adding missing rows, return false
                if ($missingRowColumns === false) {
                    return [false, $i];
                } else {
                    // if we're adding missing rows, add a new row

                    $row = new DataTableSummaryRow();
                    $row->setColumns(array('label' => $segment) + $missingRowColumns);

                    $next = $table->addRow($row);

                    if ($next !== $row) {
                        // if the row wasn't added, the table is full

                        // Summary row, has no metadata
                        $next->deleteMetadata();
                        return [$next, $i];
                    }
                }
            }

            $table = $next->getSubtable();
            if ($table === false) {
                // if the row has no table (and thus no child rows), and we're not adding
                // missing rows, return false
                if ($missingRowColumns === false) {
                    return [false, $i];
                } elseif ($i != $pathLength - 1) {
                    // create subtable if missing, but only if not on the last segment

                    $table = new DataTable();
                    $table->setMaximumAllowedRows($maxSubtableRows);
                    $table->metadata[self::COLUMN_AGGREGATION_OPS_METADATA_NAME]
                        = $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME);
                    $next->setSubtable($table);
                    // Summary row, has no metadata
                    $next->deleteMetadata();
                }
            }
        }

        return [$next, $i];
    }

    /**
     * Returns a new DataTable in which the rows of this table are replaced with the aggregatated rows of all its subtables.
     *
     * @param string|false $labelColumn If supplied the label of the parent row will be added to
     *                                 a new column in each subtable row.
     *
     *                                 If set to, `'label'` each subtable row's label will be prepended
     *                                 w/ the parent row's label. So `'child_label'` becomes
     *                                 `'parent_label - child_label'`.
     * @param bool $useMetadataColumn If true and if `$labelColumn` is supplied, the parent row's
     *                                label will be added as metadata and not a new column.
     * @return DataTable
     */
    public function mergeSubtables($labelColumn = false, $useMetadataColumn = false)
    {
        $result = new DataTable();
        $result->setAllTableMetadata($this->getAllTableMetadata());
        foreach ($this->getRowsWithoutSummaryRow() as $row) {
            $subtable = $row->getSubtable();
            if ($subtable !== false) {
                $parentLabel = $row->getColumn('label');

                // add a copy of each subtable row to the new datatable
                foreach ($subtable->getRows() as $id => $subRow) {
                    $copy = clone $subRow;

                    // if the summary row, add it to the existing summary row (or add a new one)
                    if ($id == self::ID_SUMMARY_ROW) {
                        $existing = $result->getRowFromId(self::ID_SUMMARY_ROW);
                        if ($existing === false) {
                            $result->addSummaryRow($copy);
                        } else {
                            $existing->sumRow($copy, $copyMeta = true, $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME));
                        }
                    } else {
                        if ($labelColumn !== false) {
                            // if we're modifying the subtable's rows' label column, then we make
                            // sure to prepend the existing label w/ the parent row's label. otherwise
                            // we're just adding the parent row's label as a new column/metadata.
                            $newLabel = $parentLabel;
                            if ($labelColumn == 'label') {
                                $newLabel .= ' - ' . $copy->getColumn('label');
                            }

                            // modify the child row's label or add new column/metadata
                            if ($useMetadataColumn) {
                                $copy->setMetadata($labelColumn, $newLabel);
                            } else {
                                $copy->setColumn($labelColumn, $newLabel);
                            }
                        }

                        $result->addRow($copy);
                    }
                }
            }
        }
        return $result;
    }

    /**
     * Returns a new DataTable created with data from a 'simple' array.
     *
     * See {@link addRowsFromSimpleArray()}.
     *
     * @param array $array
     * @return DataTable
     */
    public static function makeFromSimpleArray($array)
    {
        $dataTable = new DataTable();
        $dataTable->addRowsFromSimpleArray($array);
        return $dataTable;
    }

    /**
     * Creates a new DataTable instance from a serialized DataTable string.
     *
     * See {@link getSerialized()} and {@link addRowsFromSerializedArray()}
     * for more information on DataTable serialization.
     *
     * @param string $data
     * @return DataTable
     */
    public static function fromSerializedArray($data)
    {
        $result = new DataTable();
        $result->addRowsFromSerializedArray($data);
        return $result;
    }

    /**
     * Aggregates the $row columns to this table.
     *
     * $row must have a column "label". The $row will be summed to this table's row with the same label.
     *
     * @param null|array<string|int, string> $columnAggregationOps
     * @return void
     * @throws \Exception
     */
    protected function aggregateRowWithLabel(Row $row, $columnAggregationOps)
    {
        $labelToLookFor = $row->getColumn('label');
        if ($labelToLookFor === false) {
            $message = sprintf(
                "Label column not found in the table to add in addDataTable(). Row: %s",
                var_export($row->getColumns(), true)
            );
            throw new Exception($message);
        }
        $rowFound = $this->getRowFromLabel($labelToLookFor);
        // if we find the summary row in the other table, ignore it, since we're aggregating normal rows in this method.
        // the summary row is aggregated explicitly after this method is called.
        if (
            !empty($rowFound)
            && $rowFound->isSummaryRow()
        ) {
            $rowFound = false;
        }
        $this->aggregateRow($rowFound, $row, $columnAggregationOps, false);
    }

    /**
     * @param Row|false|null $thisRow
     * @param array<string|int, string>|false|null $columnAggregationOps
     */
    private function aggregateRow($thisRow, Row $otherRow, $columnAggregationOps, bool $isSummaryRow): void
    {
        if (empty($thisRow)) {
            $thisRow = new Row();
            $otherRowLabel = $otherRow->getColumn('label');
            if ($otherRowLabel !== false) {
                $thisRow->addColumn('label', $otherRowLabel);
            }
            $thisRow->setAllMetadata($otherRow->getMetadata());

            if ($isSummaryRow) {
                $this->addSummaryRow($thisRow);
            } else {
                $this->addRow($thisRow);
            }
        }

        $thisRow->sumRow($otherRow, $copyMeta = true, $columnAggregationOps);

        // if the row to add has a subtable whereas the current row doesn't
        // we simply add it (cloning the subtable)
        // if the row has the subtable already
        // then we have to recursively sum the subtables
        $subTable = $otherRow->getSubtable();
        if ($subTable) {
            $subTable->metadata[self::COLUMN_AGGREGATION_OPS_METADATA_NAME] = $columnAggregationOps;
            $thisRow->sumSubtable($subTable);
        }
    }

    /**
     * @param Row|false $row
     * @return void
     */
    protected function aggregateRowFromSimpleTable($row)
    {
        if ($row === false) {
            return;
        }
        $thisRow = $this->getFirstRow();
        if ($thisRow === false) {
            $thisRow = new Row();
            $this->addRow($thisRow);
        }
        $thisRow->sumRow($row, true, $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME));
    }

    /**
     * Unsets all queued filters.
     * @return void
     */
    public function clearQueuedFilters()
    {
        $this->queuedFilters = [];
    }

    /**
     * @return array
     */
    public function getQueuedFilters()
    {
        return $this->queuedFilters;
    }

    /**
     * @return \ArrayIterator<int, Row>
     */
    public function getIterator(): \ArrayIterator
    {
        return new \ArrayIterator($this->getRows());
    }

    /**
     * @param int $offset
     */
    public function offsetExists($offset): bool
    {
        $row = $this->getRowFromId($offset);

        return false !== $row;
    }

    /**
     * @param int $offset
     */
    public function offsetGet($offset): Row
    {
        return $this->getRowFromId($offset);
    }

    /**
     * @param int $offset
     * @param Row $value
     */
    public function offsetSet($offset, $value): void
    {
        $this->rows[$offset] = $value;
    }

    /**
     * @param int $offset
     * @throws Exception
     */
    public function offsetUnset($offset): void
    {
        $this->deleteRow($offset);
    }

    /**
     * @param string|int|null $label
     * @param array $columns
     * @param array<string, string>|null $aggregationOps
     * @throws Exception
     */
    public function sumRowWithLabel($label, array $columns, ?array $aggregationOps = null): Row
    {
        $label = $label ?? '';

        $tableRow = new DataTable\Row([DataTable\Row::COLUMNS => ['label' => $label] + $columns]);

        if ($label === RankingQuery::LABEL_SUMMARY_ROW) {
            $existingRow = $this->getSummaryRow();
        } else {
            $existingRow = $this->getRowFromLabel($label);
        }

        if (empty($existingRow)) {
            if ($label === RankingQuery::LABEL_SUMMARY_ROW) {
                $this->addSummaryRow($tableRow);
            } else {
                $this->addRow($tableRow);
            }

            $existingRow = $tableRow;
        } else {
            $existingRow->sumRow($tableRow, true, $aggregationOps);
        }
        return $existingRow;
    }

    public function setAsBuiltWithoutArchives(bool $flag): void
    {
        $this->isBuiltWithoutArchives = $flag;
    }

    public function wasBuiltWithoutArchives(): bool
    {
        return $this->isBuiltWithoutArchives;
    }
}
