<?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\DataAccess\LogQueryBuilder;

use Exception;
use Piwik\DataAccess\LogAggregator;
use Piwik\Plugin\LogTablesProvider;
class JoinTables extends \ArrayObject
{
    /**
     * @var LogTablesProvider
     */
    private $logTableProvider;
    // NOTE: joins can be specified explicitly as arrays w/ 'joinOn' keys or implicitly as table names. when
    // table names are used, the joins dependencies are assumed based on how we want to order those joins.
    // the below table list the possible dependencies of each table, and is specifically designed to enforce
    // the following order:
    // log_link_visit_action, log_action, log_visit, log_conversion, log_conversion_item
    // which means if an array is supplied where log_visit comes before log_link_visitAction, it will
    // be moved to after it.
    private $implicitTableDependencies = ['log_link_visit_action' => [], 'log_action' => ['log_link_visit_action', 'log_conversion', 'log_conversion_item', 'log_visit'], 'log_visit' => ['log_link_visit_action', 'log_action'], 'log_conversion' => ['log_link_visit_action', 'log_action', 'log_visit'], 'log_conversion_item' => ['log_link_visit_action', 'log_action', 'log_visit', 'log_conversion']];
    /**
     * Tables constructor.
     * @param array $tables
     */
    public function __construct(LogTablesProvider $logTablesProvider, $tables)
    {
        $this->logTableProvider = $logTablesProvider;
        foreach ($tables as $table) {
            $this->checkTableCanBeUsedForSegmentation($table);
        }
        $this->exchangeArray(array_values($tables));
    }
    public function getTables()
    {
        return $this->getArrayCopy();
    }
    public function addTableToJoin($tableName)
    {
        $this->checkTableCanBeUsedForSegmentation($tableName);
        $this->append($tableName);
    }
    public function hasJoinedTable($tableName)
    {
        $tables = in_array($tableName, $this->getTables());
        if ($tables) {
            return \true;
        }
        foreach ($this as $table) {
            if (is_array($table)) {
                if (!isset($table['tableAlias']) && $table['table'] === $table) {
                    return \true;
                } elseif (isset($table['tableAlias']) && $table['tableAlias'] === $table) {
                    return \true;
                }
            }
        }
        return \false;
    }
    public function hasJoinedTableManually($tableToFind, $joinToFind)
    {
        foreach ($this as $table) {
            if (is_array($table) && !empty($table['table']) && $table['table'] === $tableToFind && (!isset($table['tableAlias']) || $table['tableAlias'] === $tableToFind) && (!isset($table['join']) || strtolower($table['join']) === 'left join') && isset($table['joinOn']) && $table['joinOn'] === $joinToFind) {
                return \true;
            }
        }
        return \false;
    }
    public function getLogTable($tableName)
    {
        return $this->logTableProvider->getLogTable($tableName);
    }
    public function findIndexOfManuallyAddedTable($tableNameToFind)
    {
        foreach ($this as $index => $table) {
            if (is_array($table) && !empty($table['table']) && $table['table'] === $tableNameToFind && (!isset($table['join']) || strtolower($table['join']) === 'left join') && (!isset($table['tableAlias']) || $table['tableAlias'] === $tableNameToFind)) {
                return $index;
            }
        }
    }
    public function hasAddedTableManually($tableToFind)
    {
        $table = $this->findIndexOfManuallyAddedTable($tableToFind);
        return isset($table);
    }
    public function sort()
    {
        // we do not use $this->uasort as we do not want to maintain keys
        $tables = $this->getTables();
        // the first entry is always the FROM table
        $firstTable = array_shift($tables);
        $sorted = [$firstTable];
        // With the ability to specify which index to use, the $firstTable var may be an array
        if (is_array($firstTable) && !empty($firstTable['table'])) {
            $firstTable = $firstTable['table'];
        }
        if (strpos($firstTable, LogAggregator::LOG_TABLE_SEGMENT_TEMPORARY_PREFIX) === 0) {
            // the first table might be a temporary segment table in which case we need to keep the next one as well
            $sorted[] = array_shift($tables);
        }
        $dependencies = $this->parseDependencies($tables);
        $this->visitTableListDfs($tables, $dependencies, function ($tableInfo) use(&$sorted) {
            $sorted[] = $tableInfo;
        });
        $this->exchangeArray($sorted);
    }
    public function isTableJoinableOnVisit($tableToCheck)
    {
        $table = $this->getLogTable($tableToCheck);
        if (empty($table)) {
            return \false;
        }
        if ($table->getColumnToJoinOnIdVisit()) {
            return \true;
        }
        if ($table->getLinkTableToBeAbleToJoinOnVisit()) {
            return \true;
        }
        $otherWays = $table->getWaysToJoinToOtherLogTables();
        if (empty($otherWays)) {
            return \false;
        }
        foreach ($otherWays as $logTable => $column) {
            if ($logTable == 'log_visit' || $this->isTableJoinableOnVisit($logTable)) {
                return \true;
            }
        }
        return \false;
    }
    public function isTableJoinableOnAction($tableToCheck)
    {
        $table = $this->getLogTable($tableToCheck);
        if (empty($table)) {
            return \false;
        }
        if ($table->getColumnToJoinOnIdAction()) {
            return \true;
        }
        $otherWays = $table->getWaysToJoinToOtherLogTables();
        if (empty($otherWays)) {
            return \false;
        }
        foreach ($otherWays as $logTable => $column) {
            if ($logTable == 'log_action' || $this->isTableJoinableOnAction($logTable)) {
                return \true;
            }
        }
        return \false;
    }
    public function addTableDependency($table, $dependentTable)
    {
        if (!empty($this->implicitTableDependencies[$table])) {
            return;
        }
        $this->implicitTableDependencies[$table] = [$dependentTable];
    }
    private function checkTableCanBeUsedForSegmentation($tableName)
    {
        if (!is_array($tableName) && !$this->getLogTable($tableName)) {
            throw new Exception("Table '{$tableName}' can't be used for segmentation");
        }
    }
    private function parseDependencies(array $tables)
    {
        $dependencies = [];
        foreach ($tables as $key => &$fromInfo) {
            if (is_string($fromInfo)) {
                $dependencies[$key] = $this->assumeImplicitJoinDependencies($tables, $fromInfo);
                continue;
            }
            if (empty($fromInfo['joinOn'])) {
                continue;
            }
            $table = isset($fromInfo['tableAlias']) ? $fromInfo['tableAlias'] : $fromInfo['table'];
            $tablesInExpr = $this->parseSqlTables($fromInfo['joinOn'], $table);
            $dependencies[$key] = $tablesInExpr;
        }
        return $dependencies;
    }
    private function assumeImplicitJoinDependencies($allTablesToQuery, $table)
    {
        $implicitTableDependencies = $this->implicitTableDependencies;
        $result = [];
        if (isset($implicitTableDependencies[$table])) {
            $result = $implicitTableDependencies[$table];
            // only include dependencies that are in the list of requested tables (ie, if we want to
            // query from log_conversion joining on log_link_visit_action, we don't want to add log_visit
            // to the sql statement)
            $result = array_filter($result, function ($table) use($allTablesToQuery) {
                return $this->isInTableArray($allTablesToQuery, $table);
            });
        }
        return $result;
    }
    private function isInTableArray($tables, $table)
    {
        foreach ($tables as $entry) {
            if (is_string($entry) && $entry == $table) {
                return \true;
            }
            if (is_array($entry) && $entry['table'] == $table) {
                return \true;
            }
        }
        return \false;
    }
    private function parseSqlTables($joinOn, $self)
    {
        preg_match_all('/\\b([a-zA-Z0-9_`]+)\\.[a-zA-Z0-9_`]+\\b/', $joinOn, $matches);
        $tables = [];
        foreach ($matches[1] as $table) {
            if ($table === $self) {
                continue;
            }
            $tables[] = $table;
        }
        return $tables;
    }
    private function visitTableListDfs($tables, $dependencies, $visitor)
    {
        $visited = [];
        foreach ($tables as $index => $tableInfo) {
            if (empty($visited[$index])) {
                $this->visitTableListDfsSingle($tables, $dependencies, $visitor, $index, $visited);
            }
        }
    }
    private function visitTableListDfsSingle($tables, $dependencies, $visitor, $tableToVisitIndex, &$visited)
    {
        $visited[$tableToVisitIndex] = \true;
        $tableToVisit = $tables[$tableToVisitIndex];
        if (!empty($dependencies[$tableToVisitIndex])) {
            foreach ($dependencies[$tableToVisitIndex] as $dependencyTableName) {
                $dependentTableToVisit = $this->findTableIndex($tables, $dependencyTableName);
                if ($dependentTableToVisit === null) {
                    // sanity check, in case the dependent table is not in the list of tables to query
                    continue;
                }
                if (!empty($visited[$dependentTableToVisit])) {
                    // skip if already visited
                    continue;
                }
                // visit dependent table...
                $this->visitTableListDfsSingle($tables, $dependencies, $visitor, $dependentTableToVisit, $visited);
            }
        }
        // ...then visit current table
        $visitor($tableToVisit);
    }
    private function findTableIndex($tables, $tableToSearchFor)
    {
        foreach ($tables as $key => $info) {
            $tableName = null;
            if (is_string($info)) {
                $tableName = $info;
            } elseif (is_array($info)) {
                $tableName = isset($info['tableAlias']) ? $info['tableAlias'] : $info['table'];
            }
            if ($tableName == $tableToSearchFor) {
                return $key;
            }
        }
        return null;
    }
}
