<?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\Plugins\UserCountry;

use Matomo\Cache\Cache;
use Matomo\Cache\Transient;
use Piwik\Common;
use Piwik\Container\StaticContainer;
use Piwik\DataAccess\RawLogDao;
use Matomo\Network\IPUtils;
use Piwik\Plugins\UserCountry\LocationProvider\DisabledProvider;
use Piwik\Tracker\Visit;
use Piwik\Log\LoggerInterface;
require_once PIWIK_INCLUDE_PATH . "/plugins/UserCountry/LocationProvider.php";
/**
 * Service that determines a visitor's location using visitor information.
 *
 * Individual locations are provided by a LocationProvider instance. By default,
 * the configured LocationProvider (as determined by
 * `Common::getCurrentLocationProviderId()` is used.
 *
 * If the configured location provider cannot provide a location for the visitor,
 * the default location provider (`DefaultProvider`) is used.
 *
 * A cache is used internally to speed up location retrieval. By default, an
 * in-memory cache is used, but another type of cache can be supplied during
 * construction.
 *
 * This service can be used from within the tracker.
 */
class VisitorGeolocator
{
    public const LAT_LONG_COMPARE_EPSILON = 0.0001;
    /**
     * @var string[]
     */
    public static $logVisitFieldsToUpdate = array('location_country' => \Piwik\Plugins\UserCountry\LocationProvider::COUNTRY_CODE_KEY, 'location_region' => \Piwik\Plugins\UserCountry\LocationProvider::REGION_CODE_KEY, 'location_city' => \Piwik\Plugins\UserCountry\LocationProvider::CITY_NAME_KEY, 'location_latitude' => \Piwik\Plugins\UserCountry\LocationProvider::LATITUDE_KEY, 'location_longitude' => \Piwik\Plugins\UserCountry\LocationProvider::LONGITUDE_KEY);
    /**
     * @var Cache
     */
    protected static $defaultLocationCache = null;
    /**
     * @var LocationProvider
     */
    private $provider;
    /**
     * @var LocationProvider
     */
    private $backupProvider;
    /**
     * @var Cache
     */
    private $locationCache;
    /**
     * @var RawLogDao
     */
    protected $dao;
    /**
     * @var LoggerInterface
     */
    protected $logger;
    public function __construct(?\Piwik\Plugins\UserCountry\LocationProvider $provider = null, ?\Piwik\Plugins\UserCountry\LocationProvider $backupProvider = null, ?Cache $locationCache = null, ?RawLogDao $dao = null, ?LoggerInterface $logger = null)
    {
        if ($provider === null) {
            // note: Common::getCurrentLocationProviderId() uses the tracker cache, which is why it's used here instead
            // of accessing the option table
            $provider = \Piwik\Plugins\UserCountry\LocationProvider::getProviderById(Common::getCurrentLocationProviderId());
            if (empty($provider)) {
                Common::printDebug("GEO: no current location provider sent, falling back to '" . \Piwik\Plugins\UserCountry\LocationProvider::getDefaultProviderId() . "' one.");
                $provider = $this->getDefaultProvider();
            }
        }
        $this->provider = $provider;
        $this->backupProvider = $backupProvider ?: $this->getDefaultProvider();
        $this->locationCache = $locationCache ?: self::getDefaultLocationCache();
        $this->dao = $dao ?: new RawLogDao();
        $this->logger = $logger ?: StaticContainer::get(LoggerInterface::class);
    }
    public function getLocation($userInfo, $useClassCache = \true)
    {
        $userInfoKey = md5(implode(',', $userInfo));
        if ($useClassCache && $this->locationCache->contains($userInfoKey)) {
            return $this->locationCache->fetch($userInfoKey);
        }
        $location = $this->getLocationObject($this->provider, $userInfo);
        if (empty($location)) {
            $providerId = $this->provider->getId();
            Common::printDebug("GEO: couldn't find a location with Geo Module '{$providerId}'");
            // Only use the default provider as fallback if the configured one isn't "disabled"
            if ($providerId != DisabledProvider::ID && $providerId != $this->backupProvider->getId()) {
                Common::printDebug("Using default provider as fallback...");
                $location = $this->getLocationObject($this->backupProvider, $userInfo);
            }
        }
        $location = $location ?: array();
        if (empty($location['country_code'])) {
            $location['country_code'] = Visit::UNKNOWN_CODE;
        }
        $this->locationCache->save($userInfoKey, $location);
        return $location;
    }
    /**
     * @param array $userInfo
     * @return array|false
     */
    private function getLocationObject(\Piwik\Plugins\UserCountry\LocationProvider $provider, $userInfo)
    {
        $location = $provider->getLocation($userInfo);
        $providerId = $provider->getId();
        $ipAddress = $userInfo['ip'];
        if ($location === \false) {
            return \false;
        }
        Common::printDebug("GEO: Found IP {$ipAddress} location (provider '" . $providerId . "'): " . var_export($location, \true));
        return $location;
    }
    /**
     * Geolcates an existing visit and then updates it if it's current attributes are different than
     * what was geolocated. Also updates all conversions of a visit.
     *
     * **This method should NOT be used from within the tracker.**
     *
     * @param array $visit The visit information. Must contain an `"idvisit"` element and `"location_ip"` element.
     * @param bool $useClassCache
     * @return array|null The visit properties that were updated in the DB mapped to the updated values. If null,
     *                    required information was missing from `$visit`.
     */
    public function attributeExistingVisit($visit, $useClassCache = \true)
    {
        if (empty($visit['idvisit'])) {
            $this->logger->debug('Empty idvisit field. Skipping re-attribution..');
            return null;
        }
        $idVisit = $visit['idvisit'];
        if (empty($visit['location_ip'])) {
            $this->logger->debug('Empty location_ip field for idvisit = %s. Skipping re-attribution.', array('idvisit' => $idVisit));
            return null;
        }
        $ip = IPUtils::binaryToStringIP($visit['location_ip']);
        $location = $this->getLocation(array('ip' => $ip), $useClassCache);
        $valuesToUpdate = $this->getVisitFieldsToUpdate($visit, $location);
        if (!empty($valuesToUpdate)) {
            $this->logger->debug('Updating visit with idvisit = {idVisit} (IP = {ip}). Changes: {changes}', array('idVisit' => $idVisit, 'ip' => $ip, 'changes' => json_encode($valuesToUpdate)));
            $this->dao->updateVisits($valuesToUpdate, $idVisit);
            $this->dao->updateConversions($valuesToUpdate, $idVisit);
        } else {
            $this->logger->debug('Nothing to update for idvisit = %s (IP = {ip}). Existing location info is same as geolocated.', array('idVisit' => $idVisit, 'ip' => $ip));
        }
        return $valuesToUpdate;
    }
    /**
     * Returns location log values that are different than the values currently in a log row.
     *
     * @param array $row The visit row.
     * @param array $location The location information.
     * @return array The location properties to update.
     */
    private function getVisitFieldsToUpdate(array $row, $location)
    {
        if (isset($location[\Piwik\Plugins\UserCountry\LocationProvider::COUNTRY_CODE_KEY])) {
            $location[\Piwik\Plugins\UserCountry\LocationProvider::COUNTRY_CODE_KEY] = strtolower($location[\Piwik\Plugins\UserCountry\LocationProvider::COUNTRY_CODE_KEY]);
        }
        $valuesToUpdate = array();
        foreach (self::$logVisitFieldsToUpdate as $column => $locationKey) {
            if (empty($location[$locationKey])) {
                continue;
            }
            $locationPropertyValue = $location[$locationKey];
            $existingPropertyValue = $row[$column];
            if (!$this->areLocationPropertiesEqual($locationKey, $locationPropertyValue, $existingPropertyValue)) {
                $valuesToUpdate[$column] = $locationPropertyValue;
            }
        }
        return $valuesToUpdate;
    }
    /**
     * Re-geolocate visits within a date range for a specified site (if any).
     *
     * @param string $from A datetime string to treat as the lower bound. Visits newer than this date are processed.
     * @param string $to A datetime string to treat as the upper bound. Visits older than this date are processed.
     * @param int|null $idSite If supplied, only visits for this site are re-attributed.
     * @param int $iterationStep The number of visits to re-attribute at the same time.
     * @param callable|null $onLogProcessed If supplied, this callback is called after every row is processed.
     *                                      The processed visit and the updated values are passed to the callback.
     */
    public function reattributeVisitLogs($from, $to, $idSite = null, $iterationStep = 1000, $onLogProcessed = null)
    {
        $visitFieldsToSelect = array_merge(array('idvisit', 'location_ip'), array_keys(\Piwik\Plugins\UserCountry\VisitorGeolocator::$logVisitFieldsToUpdate));
        $conditions = array(array('visit_last_action_time', '>=', $from), array('visit_last_action_time', '<', $to));
        if (!empty($idSite)) {
            $conditions[] = array('idsite', '=', $idSite);
        }
        $self = $this;
        $this->dao->forAllLogs('log_visit', $visitFieldsToSelect, $conditions, $iterationStep, function ($logs) use($self, $onLogProcessed) {
            foreach ($logs as $row) {
                $updatedValues = $self->attributeExistingVisit($row);
                if (!empty($onLogProcessed)) {
                    $onLogProcessed($row, $updatedValues);
                }
            }
        }, $willDelete = \false);
    }
    /**
     * @return LocationProvider
     */
    public function getProvider()
    {
        return $this->provider;
    }
    /**
     * @return LocationProvider
     */
    public function getBackupProvider()
    {
        return $this->backupProvider;
    }
    private function areLocationPropertiesEqual($locationKey, $locationPropertyValue, $existingPropertyValue)
    {
        if (($locationKey == \Piwik\Plugins\UserCountry\LocationProvider::LATITUDE_KEY || $locationKey == \Piwik\Plugins\UserCountry\LocationProvider::LONGITUDE_KEY) && $existingPropertyValue != 0) {
            // floating point comparison
            return abs(($locationPropertyValue - $existingPropertyValue) / $existingPropertyValue) < self::LAT_LONG_COMPARE_EPSILON;
        } else {
            return $locationPropertyValue == $existingPropertyValue;
        }
    }
    private function getDefaultProvider()
    {
        return \Piwik\Plugins\UserCountry\LocationProvider::getProviderById(\Piwik\Plugins\UserCountry\LocationProvider::getDefaultProviderId());
    }
    public static function getDefaultLocationCache()
    {
        if (self::$defaultLocationCache === null) {
            if (class_exists('\\Piwik\\Cache\\Transient')) {
                // during the oneclickupdate from 3.x => greater, this class will be loaded, so we have to use it instead of the Matomo namespaced one
                self::$defaultLocationCache = new \Piwik\Cache\Transient();
            } else {
                self::$defaultLocationCache = new Transient();
            }
        }
        return self::$defaultLocationCache;
    }
}
