<?php

/**
 * @package         EngageBox
 * @version         6.3.8 Pro
 *
 * @author          Tassos Marinos <info@tassos.gr>
 * @link            http://www.tassos.gr
 * @copyright       Copyright © 2024 Tassos Marinos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

defined('_JEXEC') or die;

use Tassos\Vendor\GeoIp2\Database\Reader;
use Tassos\Vendor\splitbrain\PHPArchive\Tar;
use NRFramework\User;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Registry\Registry;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Factory;
use Joomla\Filesystem\Path;
use Joomla\CMS\Http\HttpFactory;

class TGeoIP
{
	/**
	 * The MaxMind GeoLite database reader
	 *
	 * @var    Reader
	 */
	private $reader = null;

	/**
	 * Records for IP addresses already looked up
	 *
	 * @var   array
	 *
	 */
	private $lookups = array();

	/**
	 *  Max Age Database before it needs an update
	 *
	 *  @var  integer
	 */
	private $maxAge = 30;

	/**
	 *  Database File name
	 *
	 *  @var  string
	 */
	private $DBFileName = 'GeoLite2-City';

	/**
	 *  Database Remote URL
	 *
	 *  @var  string
	 */
	private $DBUpdateURL = 'https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=USER_LICENSE_KEY&suffix=tar.gz';

	/**
	 *  GeoIP Enable Geolocations Documentation URL
	 * 
	 *  @var  string
	 */
	private $TGeoIPEnableDocURL = 'https://www.tassos.gr/kb/general/how-to-enable-geolocation-features-in-tassos-gr-extensions';

	/**
	 *  The IP address to look up
	 *
	 *  @var  string
	 */
	private $ip;

	/**
	 *  The License Key
	 * 
	 *  @var  string
	 */
	private $key;

	/**
	 * Public constructor. Loads up the GeoLite2 database.
	 */
	public function __construct($ip = null)
	{
		if (!function_exists('bcadd') || !function_exists('bcmul') || !function_exists('bcpow'))
		{
			require_once __DIR__ . '/fakebcmath.php';
		}

		// Check we have a valid GeoLite2 database
		$filePath = $this->getDBPath();

		if (!file_exists($filePath))
		{
			$this->reader = null;
		}

		try
		{
			$this->reader = new Reader($filePath);
		}
		// If anything goes wrong, MaxMind will raise an exception, resulting in a WSOD. Let's be sure to catch everything.
		catch(\Exception $e)
		{
			$this->reader = null;
		}

		// Setup IP
        $this->ip = $ip ?: User::getIP();

		if (in_array($this->ip, array('127.0.0.1', '::1')))
		{
			$this->ip = '';
		}
	}

	/**
	 *  Sets the license key
	 * 
	 *  @param   string
	 * 
	 *  @return  mixed
	 */
	public function setKey($key)
	{
		$this->key = $key;
	}

	/**
	 *  Retrieves the key
	 * 
	 *  @return  string
	 */
	private function getKey()
	{
		if ($this->key)
		{
			return $this->key;
		}

		$plugin = PluginHelper::getPlugin('system', 'tgeoip');
		$params = new Registry($plugin->params);

		return $params->get('license_key', '');
	}

	/**
	 *  Set the IP to look up
	 *
	 *  @param  string  $ip  The IP to look up
	 */
	public function setIP($ip)
	{
		$this->ip = $ip;
		return $this;
    }

	/**
	 * Gets the ISO country code from an IP address
	 *
	 * @return  mixed  A string with the country ISO code if found, false if the IP address is not found, null if the db can't be loaded
	 */
	public function getCountryCode()
	{
		$record = $this->getRecord();

		if ($record === false || is_null($record))
		{
			return false;
		}

		return $record->country->isoCode;
	}

	/**
	 * Gets the country name from an IP address
	 *
	 * @param   string  $locale  The locale of the country name, e.g 'de' to return the country names in German. If not specified the English (US) names are returned.
	 *
	 * @return  mixed  A string with the country name if found, false if the IP address is not found, null if the db can't be loaded
	 */
	public function getCountryName($locale = null)
	{
		$record = $this->getRecord();

		if ($record === false || is_null($record))
		{
			return false;
		}

		if (empty($locale))
		{
			return $record->country->name;
		}

		return $record->country->names[$locale];
	}

	/**
	 * Gets the continent ISO code from an IP address
	 *
	 * @return  mixed  A string with the country name if found, false if the IP address is not found, null if the db can't be loaded
	 */
	public function getContinentCode($locale = null)
	{
		$record = $this->getRecord();

		if ($record === false || is_null($record))
		{
			return false;
		}

		return $record->continent->code;
	}

	/**
	 * Gets the continent name from an IP address
	 *
	 * @param   string  $locale  The locale of the continent name, e.g 'de' to return the country names in German. If not specified the English (US) names are returned.
	 *
	 * @return  mixed  A string with the country name if found, false if the IP address is not found, null if the db can't be loaded
	 */
	public function getContinentName($locale = null)
	{
		$record = $this->getRecord();

		if ($record === false || is_null($record))
		{
			return false;
		}

		if (empty($locale))
		{
			return $record->continent;
		}

		return $record->continent->names[$locale];
	}

	/**
	 * Gets a raw record from an IP address
	 *
	 * @return  mixed  A \GeoIp2\Model\City record if found, false if the IP address is not found, null if the db can't be loaded
	 */
	public function getRecord()
	{
		if (empty($this->ip))
		{
			return false;
		}

		$ip = $this->ip;

		$needsToLoad = !array_key_exists($ip, $this->lookups);

		if ($needsToLoad)
		{
			try
			{
				if (!is_null($this->reader))
				{
					$this->lookups[$ip] = $this->reader->city($ip);
				}
				else
				{
					$this->lookups[$ip] = null;
				}
			}
			catch (Tassos\Vendor\GeoIp2\Exception\AddressNotFoundException $e)
			{
				$this->lookups[$ip] = false;
			}
			catch (\Exception $e)
			{
				// GeoIp2 could throw several different types of exceptions. Let's be sure that we're going to catch them all
				$this->lookups[$ip] = null;
			}
		}

		return $this->lookups[$ip];
	}

	/**
	 *  Gets the city's name from an IP address
	 *
     *  @param   string  $locale  The locale of the city's name, e.g 'de' to return the city names in German. If not specified the English (US) names are returned.
	 *  @return  mixed   A string with the city name if found, false if the IP address is not found, null if the db can't be loaded
	 */
	public function getCity($locale = null)
	{
		$record = $this->getRecord();

		if ($record === false || is_null($record))
		{
			return false;
		}
        
        if (empty($locale))
        {
            return $record->city->name;    
        }

		return $record->city->names[$locale];
    }
    
    /**
	 *  Gets a geographical region's (i.e. a country's province/state) name from an IP address
	 *
     *  @param   string  $locale  The locale of the regions's name, e.g 'de' to return region names in German. If not specified the English (US) names are returned.
	 *  @return  mixed   A string with the region's name if found, false if the IP address is not found, null if the db can't be loaded
	 */
	public function getRegionName($locale = null)
	{
		$record = $this->getRecord();

		if ($record === false || is_null($record))
		{
			return false;
		}
    
        // MaxMind stores region information in a 'Subdivision' object (also found in $record->city->subdivision)
        // http://maxmind.github.io/GeoIP2-php/doc/v2.9.0/class-GeoIp2.Record.Subdivision.html
        if (empty($locale))
        {
            return $record->mostSpecificSubdivision->name;
        }

		return $record->mostSpecificSubdivision->names[$locale];
    }
    
    /**
	 *  Gets a geographical region's (i.e. a country's province/state) ISO 3611-2 (alpha-2) code from an IP address
	 *
	 *  @return  mixed   A string with the region's code if found, false if the IP address is not found, null if the db can't be loaded
	 */
	public function getRegionCode()
	{
		$record = $this->getRecord();

		if ($record === false || is_null($record))
		{
			return false;
		}

        // MaxMind stores region information in a 'Subdivision' object
        // http://maxmind.github.io/GeoIP2-php/doc/v2.9.0/class-GeoIp2.Record.Subdivision.html
        return $record->mostSpecificSubdivision->isoCode;
	}
	
	/**
	 * Downloads and installs a fresh copy of the GeoLite2 City database
	 *
	 * @return  mixed  True on success, error string on failure
	 */
	public function updateDatabase()
	{
        // Try to download the package, if I get any exception I'll simply stop here and display the error
		try
		{
			$compressed = $this->downloadDatabase();
		}
		catch (\Exception $e)
		{
			return $e->getMessage();
		}

		// Write the downloaded file to a temporary location
		$target = $this->getTempFolder() . $this->DBFileName . '.tar.gz';
		if (File::write($target, $compressed) === false)
		{
			return Text::_('PLG_SYSTEM_TGEOIP_ERR_WRITEFAILED');
		}

		// Unzip database to the same temporary location
		$tar = new Tar;
		$tar->open($target);
		$extracted_files = $tar->extract($this->getTempFolder());

		$database_file = '';
		$extracted_folder = '';

		// Loop through extracted files to find the name of the extracted folder and the name of the database file
		foreach ($extracted_files as $key => $extracted_file)
		{
			if ($extracted_file->getIsdir())
			{
				$extracted_folder = $extracted_file->getPath();
			}

			if (strpos($extracted_file->getPath(), '.mmdb') === false)
			{
				continue;
			}

			$database_file = $extracted_file->getPath();
		}

		// Move database file to the correct location
		if (!File::move($this->getTempFolder() . $database_file, $this->getDBPath()))
		{
			return Text::sprintf('PLG_SYSTEM_TGEOIP_ERR_CANTWRITE', $this->getDBPath());
		}

		// Make sure the database is readable
		if (!$this->dbIsValid())
		{
			return Text::_('PLG_SYSTEM_TGEOIP_ERR_INVALIDDB');
		}

		// Delete leftovers
		File::delete($target);
		Folder::delete($this->getTempFolder() . $extracted_folder);

		return true;
	}

	/**
	 * Double check if MaxMind can actually read and validate the downloaded database
	 *
	 * @return bool
	 */
	private function dbIsValid() 
	{
		try
		{
			$reader = new Reader($this->getDBPath());
		}
		catch (\Exception $e)
		{
			return false;
		}

		return true;
	}
	
	/**
	 * Download the compressed database for the provider
	 * 
	 * @return  string  The compressed data
	 *
	 * @throws  Exception
	 */
	private function downloadDatabase()
	{
		// Make sure we have enough memory limit
		ini_set('memory_limit', '-1');

		$license_key = $this->getKey();

		if (empty($license_key))
		{
			throw new \Exception(Text::_('PLG_SYSTEM_TGEOIP_LICENSE_KEY_EMPTY') . '&nbsp;<a href="' . $this->TGeoIPEnableDocURL . '" target="_blank">' . Text::_('PLG_SYSTEM_TGEOIP_ENABLE_DOC_LINK_LABEL') . '</a>');
		}

		$http = HttpFactory::getHttp();

		$this->DBUpdateURL = str_replace('USER_LICENSE_KEY', $license_key, $this->DBUpdateURL);

		// Let's bubble up the exception, we will take care in the caller
		$response   = $http->get($this->DBUpdateURL);
		$compressed = $response->body;

		// 401 is thrown if you have incorrect credentials or wrong license key
		if ($response->code == 401)
		{
			throw new \Exception(Text::_('PLG_SYSTEM_TGEOIP_ERR_WRONG_LICENSE_KEY'));
		}
		
		// Generic check on valid HTTP code
		if ($response->code > 299)
		{
			throw new \Exception(Text::_('PLG_SYSTEM_TGEOIP_ERR_MAXMIND_GENERIC'));
		}

		// An empty file indicates a problem with MaxMind's servers
		if (empty($compressed))
		{
			throw new \Exception(Text::_('PLG_SYSTEM_TGEOIP_ERR_EMPTYDOWNLOAD'));
		}

		// Sometimes you get a rate limit exceeded
		if (stristr($compressed, 'Rate limited exceeded') !== false)
		{
			throw new \Exception(Text::_('PLG_SYSTEM_TGEOIP_ERR_MAXMINDRATELIMIT'));
		}

		return $compressed;
	}

	/**
	 * Reads (and checks) the temp Joomla folder
	 *
	 * @return string
	 */
	private function getTempFolder()
	{
		$ds = DIRECTORY_SEPARATOR;

		$tmpdir = Factory::getConfig()->get('tmp_path');

		if (realpath($tmpdir) == $ds . 'tmp')
		{
			$tmpdir = JPATH_SITE . $ds . 'tmp';
		}
		
		elseif (!is_dir($tmpdir))
		{
			$tmpdir = JPATH_SITE . $ds . 'tmp';
		}

		return Path::clean(trim($tmpdir) . $ds);
	}

	/**
	 *  Returns Database local file path
	 *
	 *  @return  string
	 */
	private function getDBPath()
	{
		return JPATH_ROOT . '/plugins/system/tgeoip/db/' . $this->DBFileName . '.mmdb';
	}

	/**
	 * Does the GeoIP database need update?
	 *
	 * @return  boolean
	 */
	public function needsUpdate()
	{
		// Get the modification time of the database file
		$modTime = @filemtime($this->getDBPath());

		// This is now
		$now = time();

		// Minimum time difference
		$threshold = $this->maxAge * 24 * 3600;

		// Do we need an update?
		$needsUpdate = ($now - $modTime) > $threshold;

		return $needsUpdate;
	}
}