<?php

class Wslm_LicenseServer {
	protected $tablePrefix = 'wp_';
	/** @var wpdb */
	protected $wpdb;
	private $get = array();
	private $post = array();

	/**
	 * @var int How soon to delete probably-unused license tokens.
	 */
	protected $tokenDeletionThreshold = 60;

	/** @var Wslm_Database */
	private $db;

	/**
	 * @var callable|null
	 */
	private $isDevSiteCallback;

	public function __construct($tablePrefix = null, $database = null, $isDevSiteCallback = null) {
		$this->db = $database;
		$this->isDevSiteCallback = $isDevSiteCallback;

		$this->tablePrefix = $tablePrefix;
		if ( isset($GLOBALS['wpdb']) ) {
			$this->wpdb = $GLOBALS['wpdb'];
		}
		if ( !isset($this->tablePrefix) ) {
			$this->tablePrefix = isset($this->wpdb) ? $this->wpdb->prefix : 'wp_';
		}
		$this->get = $_GET;
		$this->post = $_POST;

		if ( function_exists('add_action') ) {
			add_action('init', array($this, 'addRewriteRules'));
			add_filter('query_vars', array($this, 'addQueryVars'));

			add_action('template_redirect', array($this, 'dispatchRequest'), 5);

			$cronHook = 'wslm_delete_unused_tokens';
			if ( function_exists('wp_next_scheduled') && is_admin() ) {
				if ( !wp_next_scheduled($cronHook) && !defined('WP_INSTALLING') ) {
					wp_schedule_event(time(), 'daily', $cronHook);
				}
			}
			add_action($cronHook, array($this, 'deleteUnusedTokens'));
		}
	}

	/**
	 * Generate a new license.
	 *
	 * The $licenseData array determines the properties of the new license.
	 * The supported fields are:
	 *        'product_slug' - The product associated with the license. Required.
	 *        'product_id'   - A numeric product ID.
	 *        'customer_id'  - A numeric customer ID.
	 *        'max_sites'    - On how many sites can the license be activated. Null = no limit.
	 *        'expires_on'   - When the license expires (e.g. "2015-01-31 12:00"). Null = never expire.
	 *
	 * The 'product_id' and 'customer_id' fields are primarily intended for your own use,
	 * i.e. to help integrate this licensing library with your existing store. The license
	 * server doesn't actually use them for anything.
	 *
	 * @param array $licenseData
	 * @return Wslm_ProductLicense
	 * @throws InvalidArgumentException
	 */
	public function generateLicense($licenseData) {
		$licenseData = array_merge(array(
			'product_id'  => 0,
			'customer_id' => 0,
			'max_sites'   => null,
			'expires_on'  => null,
			'issued_on'   => date('Y-m-d H:i:s'),
			'license_key' => $this->generateRandomString(32),
		), $licenseData);

		if ( empty($licenseData['product_slug']) ) {
			throw new InvalidArgumentException("Product slug must not be empty");
		}

		return $this->saveLicense($licenseData);
	}

	/**
	 * Delete a license and associated tokens.
	 *
	 * @param Wslm_ProductLicense $license
	 */
	public function deleteLicense($license) {
		$this->wpdb->delete(
			$this->tablePrefix . 'licenses',
			array('license_id' => $license['license_id'])
		);
		//Delete associated tokens.
		$this->wpdb->delete(
			$this->tablePrefix . 'tokens',
			array('license_id' => $license['license_id'])
		);
	}

	/**
	 * Update or insert a license.
	 *
	 * @param Wslm_ProductLicense|array $license
	 * @return Wslm_ProductLicense
	 */
	public function saveLicense($license) {
		if ( is_array($license) ) {
			$license = new Wslm_ProductLicense($license);
		}
		$data = $license->getData();

		//The license object might have some virtual or computed fields that don't exist in the DB.
		//If we try to update/insert those, we'll get an SQL error. So lets filter the data array
		//to ensure only valid fields are included in the query.
		$licenseDbFields = array(
			'license_id',
			'license_key',
			'product_id',
			'product_slug',
			'customer_id',
			'status',
			'issued_on',
			'expires_on',
			'max_sites',
			'base_price',
			'renewal_price',
		);
		$licenseDbFields = apply_filters('wslm_license_db_fields', $licenseDbFields);
		$data = array_intersect_key($data, array_flip($licenseDbFields));

		if ( is_numeric($data['expires_on']) ) {
			$data['expires_on'] = date('Y-m-d H:i:s', $data['expires_on']);
		}

		if ( $license->get('license_id') === null ) {
			//wpdb converts null values to "0" which is not what we want.
			//When inserting, we can simply strip them and let the DB fill in the blanks with NULLs.
			$data = array_filter($data, __CLASS__ . '::isNotNull');
			$this->wpdb->insert($this->tablePrefix . 'licenses', $data);
			$license['license_id'] = $this->wpdb->insert_id;
		} else {
			//When updating, we need to check for nulls and treat them differently,
			//so we can't use $wpdb->update here.
			$query = "UPDATE {$this->tablePrefix}licenses SET ";
			$expressions = array();
			foreach ($data as $field => $value) {
				$expressions[] = $field . ' = ' . (($value === null) ? 'NULL' : $this->wpdb->prepare('%s', $value));
			}
			$query .= implode(', ', $expressions);
			$query .= ' WHERE license_id = ' . absint($license['license_id']);
			$this->wpdb->query($query);
		}

		//Save add-ons.
		$this->wpdb->query('START TRANSACTION');
		//Delete old add-on records first.
		$query = $this->wpdb->prepare(
			"DELETE FROM `{$this->tablePrefix}license_addons` WHERE license_id = %d",
			$license['license_id']
		);
		$this->wpdb->query($query);

		//Insert new license-to-add-on relations.
		if ( !empty($license['addons']) ) {
			$query =
				"INSERT INTO `{$this->tablePrefix}license_addons`(license_id, addon_id)
				SELECT %d AS license_id, addon_id 
				FROM `{$this->tablePrefix}addons`";
			$query = $this->wpdb->prepare($query, $license['license_id']);

			$preparedSlugs = array();
			foreach ($license['addons'] as $slug => $ignored) {
				$preparedSlugs[] = $this->wpdb->prepare('%s', $slug);
			}
			$query .= ' WHERE slug IN (' . implode(', ', $preparedSlugs) . ')';

			$this->wpdb->query($query);
		}
		$this->wpdb->query('COMMIT');

		return $license;
	}

	public static function isNotNull($value) {
		return $value !== null;
	}

	public function dispatchRequest() {
		if ( get_query_var('licensing_api') != '1' ) {
			return;
		}

		$action = get_query_var('license_action');
		if ( empty($action) ) {
			$action = 'get_license';
		}
		$productSlug = get_query_var('license_product');
		$licenseKey = get_query_var('license_key');
		$token = get_query_var('license_token');

		switch ($action) {
			case 'get_license':
				$this->actionGetLicense($productSlug, $licenseKey, $token);
				break;
			case 'license_site':
				$this->actionLicenseSite($productSlug, $licenseKey);
				break;
			case 'unlicense_site':
				$this->actionUnlicenseSite($productSlug, $licenseKey, $token);
				break;
			default:
				$this->outputError('invalid_action', 'Unsupported API action "' . $action . '"', 400);
				break;
		}

		exit;
	}

	protected function actionGetLicense($productSlug, $licenseKey = null, $token = null) {
		$this->requireRequestMethod('GET');
		$inputSiteUrl = isset($this->get['site_url']) ? strval($this->get['site_url']) : '';

		$license = $this->validateLicenseRequest($productSlug, $licenseKey, $token, $this->get);
		$this->outputResponse(array(
			'license' => $this->prepareLicenseForOutput($license, !empty($token), $inputSiteUrl),
		));

		if ( !empty($token) ) {
			$this->logUpdateCheck($token);
		}
	}

	/**
	 * Check if the specified license exists, and quit with an API error if not.
	 * Returns the requested license on success.
	 *
	 * @param string $productSlug
	 * @param string|null $licenseKey
	 * @param string|null $token
	 * @param array $params
	 * @return Wslm_ProductLicense
	 */
	protected function validateLicenseRequest($productSlug, $licenseKey, $token = null, $params = array()) {
		$usingToken = !empty($token);
		if ( $usingToken ) {
			$siteUrl = $this->sanitizeSiteUrl(isset($params['site_url']) ? strval($params['site_url']) : '');
		} else {
			$siteUrl = null;
		}

		$license = $this->verifyLicenseExists($productSlug, $licenseKey, $token, $siteUrl);
		if ( is_wp_error($license) ) {
			$this->outputError(
				$license->get_error_code(),
				$license->get_error_message(),
				$license->get_error_data()
			);
			exit;
		} else {
			return $license;
		}

	}

	/**
	 * Check if the specified license key or token exists and return the corresponding license.
	 *
	 * If you specify both a token and a site URL this method will also verify that the token
	 * matches the site URL.
	 *
	 * @param string $productSlug
	 * @param string $licenseKey
	 * @param string|null $token Takes precedence over the license key.
	 * @param string|null $siteUrl
	 * @return Wslm_ProductLicense|WP_Error A license object, or WP_Error if the license doesn't exist or doesn't match the URL.
	 */
	public function verifyLicenseExists($productSlug, $licenseKey, $token = null, $siteUrl = null) {
		if ( empty($licenseKey) && empty($token) ) {
			return new WP_Error('not_found', 'You must specify a license key or a site token.', 400);
		}

		$license = $this->loadLicense($licenseKey, $token, true);
		if ( empty($license) ) {
			if ( !empty($token) ) {
				return new WP_Error('not_found', 'Invalid site token.', 404);
			} else {
				return new WP_Error('not_found', 'Invalid license key. Please verify the key or contact the developer for assistance.', 404);
			}
		}

		if ( $license['product_slug'] !== $productSlug ) {
			if ( $license->hasAddOn($productSlug) ) {
				//This request is for an add-on, not the main product. That's fine.
			} else {
				return new WP_Error('not_found', 'This license key is for a different product.', 404);
			}
		}

		//Make sure the site token was actually issued to that site and not another one.
		if ( $siteUrl !== null && $token !== null ) {
			$siteUrl = $this->sanitizeSiteUrl($siteUrl);
			if ( !$this->isValidUrl($siteUrl) ) {
				return new WP_Error('invalid_site_url', 'You must specify a valid site URL when using a site token.', 400);
			}
			$expectedUrl = $this->sanitizeSiteUrl($license['site_url']);
			if ( $siteUrl != $expectedUrl ) {
				return new WP_Error('wrong_site', 'This token is associated with a different site.', 400);
			}
		}

		return $license;
	}

	/**
	 * Retrieve a license by license key or token.
	 *
	 * If you specify a token, this method will ignore $licenseKey and just
	 * look for the token. The returned license object will also include
	 * the URL of the site associated with that token in a 'site_url' field.
	 *
	 * @param string|int|null $licenseKeyOrId
	 * @param string|null $token
	 * @return Wslm_ProductLicense|null A license object, or null if the license doesn't exist.
	 * @throws InvalidArgumentException
	 */
	public function loadLicense($licenseKeyOrId, $token = null, $alwaysTreatAsKey = false) {
		if ( !empty($token) ) {
			$query = "SELECT licenses.*, tokens.site_url
				 FROM
				 	`{$this->tablePrefix}licenses` AS licenses
				 	JOIN `{$this->tablePrefix}tokens` AS tokens
				 	ON licenses.license_id = tokens.license_id
				 WHERE tokens.token = ?";
			$params = array($token);
		} else if (
			is_numeric($licenseKeyOrId)
			&& (!is_string($licenseKeyOrId) || (strlen($licenseKeyOrId) < 13))
			&& (!$alwaysTreatAsKey)
		) {
			$query =
				"SELECT licenses.* FROM `{$this->tablePrefix}licenses` AS licenses
				 WHERE license_id = ?";
			$params = array($licenseKeyOrId);
		} else if ( !empty($licenseKeyOrId) ) {
			$query =
				"SELECT licenses.* FROM `{$this->tablePrefix}licenses` AS licenses
				 WHERE license_key = ?";
			$params = array($licenseKeyOrId);
		} else {
			throw new InvalidArgumentException('You must specify a license key or a site token.');
		}

		$license = $this->db->getRow($query, $params);
		if ( !empty($license) ) {
			//Also include the list of sites and add-ons associated with this license.
			$license['sites'] = $this->loadLicenseSites($license['license_id']);
			$license['addons'] = $this->loadLicenseAddOns($license['license_id']);
			$license = new Wslm_ProductLicense($license);

			$license['renewal_url'] = 'https://adminmenueditor.com/renew-license/';
			$license['upgrade_url'] = 'https://adminmenueditor.com/upgrade-license/';
		} else {
			$license = null;
		}
		return $license;
	}

	/**
	 * @param string $licenseKey
	 * @return Wslm_ProductLicense|null
	 */
	public function loadLicenseByKey($licenseKey) {
		return $this->loadLicense($licenseKey, null, true);
	}

	protected function loadLicenseSites($licenseId) {
		$licensedSites = $this->db->getResults(
			"SELECT site_url, token, issued_on
			 FROM {$this->tablePrefix}tokens
			 WHERE license_id = ?",
			array($licenseId)
		);

		//Flag development sites in the response so the plugin can display them differently if needed.
		if ( is_array($licensedSites) ) {
			foreach ($licensedSites as &$site) {
				$site['is_dev_site'] = $this->isDevSite($site['site_url']);
			}
			unset($site);
		}

		return $licensedSites;
	}

	protected function loadLicenseAddOns($licenseId) {
		$rows = $this->db->getResults(
			"SELECT addons.slug, addons.name
			 FROM 
			 	{$this->tablePrefix}license_addons AS license_addons 
			 	JOIN {$this->tablePrefix}addons AS addons
			 	ON (license_addons.addon_id = addons.addon_id)  
			 WHERE license_addons.license_id = ?",
			array($licenseId)
		);

		$addOns = array();
		foreach ($rows as $row) {
			$addOns[$row['slug']] = $row['name'];
		}
		return $addOns;
	}

	/**
	 * @param Wslm_ProductLicense $license
	 * @param bool $usingToken
	 * @param string $inputSiteUrl
	 * @return array
	 */
	public function prepareLicenseForOutput($license, $usingToken = false, $inputSiteUrl = '') {
		$data = $license->getData();
		$data['status'] = $license->getStatus();

		//Ensure timestamps are formatted consistently.
		foreach (array('issued_on', 'expires_on') as $datetimeField) {
			if ( isset($data[$datetimeField]) ) {
				$data[$datetimeField] = gmdate('c', strtotime($data[$datetimeField]));
			}
		}

		$visibleFields = array_fill_keys(array(
			'license_key',
			'product_slug',
			'status',
			'issued_on',
			'max_sites',
			'expires_on',
			'sites',
			'site_url',
			'error',
			'renewal_url',
			'addons',
		), true);
		if ( $usingToken ) {
			$visibleFields = array_merge($visibleFields, array(
				'license_key' => false,
				'sites'       => false,
			));
		}
		if ( function_exists('apply_filters') ) {
			$visibleFields = apply_filters('wslm_api_visible_license_fields', $visibleFields);
		}
		$data = array_intersect_key($data, array_filter($visibleFields));

		//Backwards compatibility: Match the protocol of the input URL for data that contains site URLs.
		if ( !empty($inputSiteUrl) ) {
			if ( !empty($data['site_url']) && is_string($data['site_url']) ) {
				$data['site_url'] = $this->matchValidInputProtocol($data['site_url'], $inputSiteUrl);
			}

			if ( isset($data['sites']) ) {
				$sites = $data['sites'];
				if ( is_array($sites) ) {
					foreach ($sites as $key => $site) {
						if ( !empty($site['site_url']) && is_string($site['site_url']) ) {
							$sites[$key]['site_url'] = $this->matchValidInputProtocol($site['site_url'], $inputSiteUrl);
						}
					}
					$data['sites'] = $sites;
				}
			}
		}

		return $data;
	}

	/**
	 * Record that a specific site just checked for updates.
	 *
	 * @param string $token Unique site token.
	 */
	public function logUpdateCheck($token) {
		$query = "UPDATE {$this->tablePrefix}tokens SET last_update_check = NOW() WHERE token = ?";
		$this->db->query($query, array($token));
	}

	/**
	 * Delete tokens associated with sites that haven't checked for updates in the last X days.
	 */
	public function deleteUnusedTokens() {
		$query =
			"DELETE FROM {$this->tablePrefix}tokens 
			 WHERE 
			    last_update_check IS NOT NULL
			    AND last_update_check < DATE_SUB(NOW(), INTERVAL ? DAY)";
		$this->db->query($query, array($this->tokenDeletionThreshold));

		//TODO: Also delete sites that were licensed a long time ago and that have never checked for updates.
	}

	protected function actionLicenseSite($productSlug, $licenseKey) {
		$this->requireRequestMethod('POST');
		$license = $this->validateLicenseRequest($productSlug, $licenseKey);

		//Is the license still valid?
		if ( !$license->isValid() ) {
			if ( $license->getStatus() == 'expired' ) {
				$renewalUrl = $license->get('renewal_url');
				$this->outputError(
					'expired_license',
					sprintf(
						'This license key has expired. You can still use the plugin (without activating the key), but you will need to %1$srenew the license%2$s to receive updates.',
						$renewalUrl ? '<a href="' . esc_attr($renewalUrl) . '" target="_blank">' : '',
						$renewalUrl ? '</a>' : ''
					),
					400
				);
			} else {
				$this->outputError('invalid_license', 'This license key is invalid or has expired.', 400);
			}
			return;
		}

		$inputSiteUrl = isset($this->post['site_url']) ? strval($this->post['site_url']) : '';
		if ( !$this->isValidUrl($inputSiteUrl) ) {
			$this->outputError('site_url_expected', "Missing or invalid site URL.", 400);
			return;
		}
		$siteUrl = $this->sanitizeSiteUrl($inputSiteUrl);

		//Maybe the site is already licensed?
		$token = $this->getTokenForLicensedSite($siteUrl, $license['license_id']);
		if ( !empty($token) ) {
			$this->outputResponse(array(
				'site_token' => $token,
				'license'    => $this->prepareLicenseForOutput($license, false, $inputSiteUrl),
			));
			return;
		}

		//Check the number of sites already licensed and see if we can add another one.
		if ( $license['max_sites'] !== null ) {
			$effectiveLicensedSiteCount = $this->getLicensedProductionSiteCount($license);
			if ( $effectiveLicensedSiteCount >= intval($license['max_sites']) ) {
				$upgradeUrl = $license->get('upgrade_url');
				$this->outputError(
					'max_sites_reached',
					sprintf(
						'You have reached the maximum number of sites allowed by your license. '
						. 'To activate it on another site, you need to either %1$supgrade the license%2$s '
						. 'or remove it from one of your existing sites in the <span class="%3$s">"Manage Sites"</span> tab.',
						$upgradeUrl ? '<a href="' . esc_attr($upgradeUrl) . '" target="_blank">' : '',
						$upgradeUrl ? '</a>' : '',
						'ame-open-tab-manage-sites'
					),
					400,
					$this->prepareLicenseForOutput($license, false, $inputSiteUrl)
				);
				return;
			}
		}

		//If the site was already associated with another key, remove that association. Only one key per site.
		//Local sites are an exception because they're not unique. Many developers use http://localhost/.
		if ( !$this->isLocalHost($siteUrl) ) {
			$otherToken = $this->getTokenForSiteExceptLicense($siteUrl, $productSlug, $license['license_id']);
			if ( !empty($otherToken) ) {
				$this->wpdb->delete($this->tablePrefix . 'tokens', array('token' => $otherToken));
			}
		}

		//Everything checks out, lets create a new token.
		$token = $this->generateRandomString(32);
		$this->wpdb->insert(
			$this->tablePrefix . 'tokens',
			array(
				'license_id' => $license['license_id'],
				'token'      => $token,
				'site_url'   => $siteUrl,
				'issued_on'  => date('Y-m-d H:i:s'),
			)
		);

		//Reload the license to ensure it includes the changes we just made.
		$license = $this->loadLicenseByKey($licenseKey);

		$response = array(
			'site_token' => $token,
			'license'    => $this->prepareLicenseForOutput($license, false, $inputSiteUrl),
		);

		//Add a notice to expired licenses.
		if ( $license->getStatus() === 'expired' ) {
			$renewalUrl = $license->get('renewal_url');
			$response['notice'] = array(
				'message' => sprintf(
					'Your access to updates and support has expired. To receive updates, please %1$srenew your license%2$s.',
					$renewalUrl ? '<a href="' . esc_attr($renewalUrl) . '" target="_blank">' : '',
					$renewalUrl ? '</a>' : ''
				),
				'class'   => 'notice-warning',
			);
		}

		$this->outputResponse($response);
	}

	private function getTokenForLicensedSite($sanitizedSiteUrl, $licenseId): ?string {
		if ( empty($sanitizedSiteUrl) ) {
			return null;
		}

		$query = "SELECT token FROM {$this->tablePrefix}tokens WHERE site_url = %s AND license_id = %d";
		$token = $this->wpdb->get_var($this->wpdb->prepare(
			$query,
			$sanitizedSiteUrl, $licenseId
		));

		if ( empty($token) && $this->isUrlFormatTransitionPeriod() ) {
			//Try again but with the old URL format (http:// instead of protocol-relative).
			/** @noinspection HttpUrlsUsage */
			$oldFormatUrl = preg_replace('@^//@', 'http://', $sanitizedSiteUrl);
			$token = $this->wpdb->get_var($this->wpdb->prepare(
				$query,
				$oldFormatUrl, $licenseId
			));
		}

		return $token;
	}

	/**
	 * Find a token for the specified site URL that is associated with a different license.
	 */
	private function getTokenForSiteExceptLicense($sanitizedSiteUrl, $productSlug, $exceptLicenseId): ?string {
		if ( empty($sanitizedSiteUrl) ) {
			return null;
		}

		$query = "SELECT tokens.token
				 FROM {$this->tablePrefix}tokens AS tokens
					JOIN {$this->tablePrefix}licenses AS licenses
					ON tokens.license_id = licenses.license_id
				 WHERE
					tokens.site_url = %s
					AND licenses.product_slug = %s
					AND licenses.license_id <> %d";

		$otherToken = $this->wpdb->get_var($this->wpdb->prepare(
			$query,
			$sanitizedSiteUrl, $productSlug, $exceptLicenseId
		));

		if ( empty($otherToken) && $this->isUrlFormatTransitionPeriod() ) {
			//Try again but with the old URL format.
			/** @noinspection HttpUrlsUsage */
			$oldFormatUrl = preg_replace('@^//@', 'http://', $sanitizedSiteUrl);
			$otherToken = $this->wpdb->get_var($this->wpdb->prepare(
				$query,
				$oldFormatUrl, $productSlug, $exceptLicenseId
			));
		}

		return $otherToken;
	}

	public function generateRandomString($length, $alphabet = null) {
		if ( $alphabet === null ) {
			$alphabet = 'ABDEFGHJKLMNOPQRSTVWXYZ0123456789';
			//U and C intentionally left out to lessen the chances of generating an obscene string.
			//"I" was left out because it's visually similar to 1.
		}
		$maxIndex = strlen($alphabet) - 1;
		$str = '';
		for ($i = 0; $i < $length; $i++) {
			$str .= substr($alphabet, rand(0, $maxIndex), 1);
		}
		return $str;
	}

	protected function isLocalHost($siteUrl) {
		$host = @parse_url($siteUrl, PHP_URL_HOST);
		if ( empty($host) ) {
			return false;
		}
		return (preg_match('/\.?(localhost|local|dev)$/', $host) == 1);
	}

	/**
	 * Get the number of licensed sites for a given license, excluding development/staging
	 * environments and localhost sites.
	 *
	 * @param Wslm_ProductLicense $license
	 * @param bool $reloadLicensedSites
	 * @return int
	 */
	private function getLicensedProductionSiteCount($license, bool $reloadLicensedSites = false): int {
		if ( !($license instanceof Wslm_ProductLicense) ) {
			throw new InvalidArgumentException('Expected a Wslm_ProductLicense object.');
		}

		if ( $reloadLicensedSites ) {
			$license['sites'] = $this->loadLicenseSites($license['license_id']);
		}

		$sites = $license['sites'];
		if ( !is_array($sites) || empty($sites) ) {
			return 0;
		}

		$count = 0;
		foreach ($sites as $site) {
			if ( isset($site['site_url']) && !$this->isDevSite($site['site_url']) ) {
				$count++;
			}
		}

		return $count;
	}

	private function isDevSite($siteUrl): bool {
		if ( $this->isDevSiteCallback ) {
			return call_user_func($this->isDevSiteCallback, $siteUrl);
		}
		return false;
	}

	protected function actionUnlicenseSite($productSlug, $licenseKey = null, $token = null) {
		$this->requireRequestMethod('POST');
		$license = $this->validateLicenseRequest($productSlug, $licenseKey, $token, $this->post);

		$inputSiteUrl = isset($this->post['site_url']) ? strval($this->post['site_url']) : '';
		$siteUrl = $this->sanitizeSiteUrl($inputSiteUrl);
		$usingToken = !empty($token);

		$response = array('license' => $this->prepareLicenseForOutput($license, $usingToken, $inputSiteUrl),);

		if ( !$usingToken ) {
			$token = $this->getTokenForLicensedSite($siteUrl, $license['license_id']);
		}

		if ( empty($token) ) {
			//The user tried to un-license a site that wasn't licensed in the first place. Still,
			//the desired end state - site not licensed - has been achieved, so treat it as a success.
			$response['notice'] = "The specified site wasn't licensed in the first place.";
		} else {
			$this->wpdb->delete(
				$this->tablePrefix . 'tokens',
				array(
					'token'      => $token,
					'license_id' => $license['license_id'],
				)
			);

			//Reload the license to ensure the site list is correct.
			$license = $this->loadLicenseByKey($license['license_key']);
			$response['license'] = $this->prepareLicenseForOutput($license, $usingToken, $inputSiteUrl);

			$response = array_merge($response, array(
				'site_token_removed' => $token,
				'site_url'           => $this->matchValidInputProtocol($siteUrl, $inputSiteUrl),
			));
		}
		$this->outputResponse($response);
	}

	protected function requireRequestMethod($httpVerbs) {
		$httpVerbs = array_map('strtoupper', (array)$httpVerbs);
		if ( !in_array(strtoupper($_SERVER['REQUEST_METHOD']), $httpVerbs) ) {
			header('Allow: ' . implode(', ', $httpVerbs));
			$this->outputError(
				'invalid_method',
				'This resource does not support the ' . $_SERVER['REQUEST_METHOD'] . ' method.',
				405
			);
			exit;
		}
	}

	protected function outputError($code, $message, $httpStatus = null, $licenseData = null) {
		$httpStatus = (isset($httpStatus) && is_numeric($httpStatus)) ? $httpStatus : 500;
		$response = array('error' => array('code' => $code, 'message' => $message),);
		if ( isset($licenseData) ) {
			$response['license'] = $licenseData;
		}
		$this->outputResponse($response, $httpStatus);
	}

	private function outputResponse($body, $httpStatus = 200) {
		status_header($httpStatus);
		header('Content-Type: application/json');
		echo wsh_pretty_json(json_encode($body));
	}

	private function isValidUrl($url) {
		$parts = @parse_url($url);
		return !empty($url) && !empty($parts) && isset($parts['host']);
	}

	private function sanitizeSiteUrl($url) {
		//Convert Punycode domain names to UTF-8.
		$domain = @parse_url($url, PHP_URL_HOST);
		if ( $domain && preg_match('/xn--./', $domain) && is_callable('idn_to_utf8') ) {
			/** @noinspection PhpComposerExtensionStubsInspection */
			$converted = idn_to_utf8($domain);
			if ( $converted && ($converted !== $domain) ) {
				$url = preg_replace_callback(
					'/' . preg_quote($domain, '/') . '/',
					function () use ($converted) {
						return $converted;
					},
					$url,
					1
				);
			}
		}

		//Convert to a protocol-relative URL.
		$url = preg_replace('@^https?://@i', '//', trim($url));

		//Remove trailing slashes.
		return rtrim($url, '/');
	}

	private function isUrlFormatTransitionPeriod(): bool {
		//For a few while after switching to protocol-relative URLs, we need to accept both formats.
		$transitionEnd = mktime(0, 0, 0, 1, 20, 2026);
		return (time() < $transitionEnd);
	}

	/**
	 * Ensure the output URL uses the same protocol as the input URL, provided that
	 * it's either http://, https:// or protocol-relative (//example.com).
	 *
	 * @param string $outputUrl
	 * @param string $inputUrl
	 * @return string
	 */
	private function matchValidInputProtocol(string $outputUrl, string $inputUrl): string {
		$inputUrlProtocol = '';
		if ( preg_match('@^(https?:)?//@i', $inputUrl, $matches) ) {
			$inputUrlProtocol = strtolower($matches[0]);
		}
		$outputUrlProtocol = '';
		if ( preg_match('@^(https?:)?//@i', $outputUrl, $matches) ) {
			$outputUrlProtocol = strtolower($matches[0]);
		}

		if ( $inputUrlProtocol === $outputUrlProtocol ) {
			return $outputUrl; //No changes needed.
		}

		if ( !empty($inputUrlProtocol) ) {
			//Replace the protocol in the output URL with the one from the input URL.
			$outputUrl = $inputUrlProtocol . substr($outputUrl, strlen($outputUrlProtocol));
		} else if ( $outputUrlProtocol !== '//' ) {
			//Convert the output URL to protocol-relative.
			$outputUrl = '//' . substr($outputUrl, strlen($outputUrlProtocol));
		}

		return $outputUrl;
	}

	/**
	 * Retrieve a customer's licenses. You can optionally specify a slug to retrieve only
	 * licenses for that product.
	 *
	 * @param int $customerId
	 * @param string|null $productSlug
	 * @return Wslm_ProductLicense[] An array of licenses ordered by status and expiry (newest valid licenses first).
	 */
	public function getCustomerLicenses($customerId, $productSlug = null) {
		//This UNION hack is due to the fact that, when dealing with businesses, different people
		//can renew or upgrade the same license. People have different emails, so they count as different
		//customers. We need all of them to be able to access the license.
		$query = $this->wpdb->prepare(
			"SELECT customerLicenses.*
			FROM (
			    SELECT licenses.*
				FROM {$this->tablePrefix}licenses AS licenses
				WHERE (licenses.customer_id = %d)
				
			    UNION DISTINCT
			    
			    SELECT order_licenses.*
			    FROM {$this->tablePrefix}orders AS orders JOIN {$this->tablePrefix}licenses AS order_licenses 
			    	ON (orders.license_id = order_licenses.license_id)
				WHERE (orders.customer_id = %d)
			) AS customerLicenses
			WHERE 1",
			array($customerId, $customerId)
		);
		if ( $productSlug !== null ) {
			$query .= $this->wpdb->prepare(' AND product_slug=%s', $productSlug);
		}
		$query .= ' ORDER BY status ASC, expires_on IS NULL DESC, expires_on DESC'; //Valid licenses first.

		$rows = $this->wpdb->get_results($query, ARRAY_A);
		$licenses = array();
		if ( is_array($rows) ) {
			foreach ($rows as $row) {
				$row['addons'] = $this->loadLicenseAddOns($row['license_id']);
				$licenses[] = new Wslm_ProductLicense($row);
			}
		}
		return $licenses;
	}

	public function addRewriteRules() {
		$apiRewriteRules = array(
			'licensing_api/products/([^/\?]+)/licenses/bytoken/([^/\?]+)(?:/([a-z0-9_]+))?' =>
				'index.php?licensing_api=1&license_product=$matches[1]&license_token=$matches[2]&license_action=$matches[3]',

			'licensing_api/products/([^/\?]+)/licenses/([^/\?]+)(?:/([a-z0-9_]+))?' =>
				'index.php?licensing_api=1&license_product=$matches[1]&license_key=$matches[2]&license_action=$matches[3]',
		);

		foreach ($apiRewriteRules as $pattern => $redirect) {
			add_rewrite_rule($pattern, $redirect, 'top');
		}

		//Flush the rules only if they didn't exist before.
		$wp_rewrite = $GLOBALS['wp_rewrite'];
		/** @var WP_Rewrite $wp_rewrite */
		$missingRules = array_diff_assoc($apiRewriteRules, $wp_rewrite->wp_rewrite_rules());
		if ( !empty($missingRules) ) {
			flush_rewrite_rules(false);
		}
	}

	public function addQueryVars($queryVariables) {
		$licensingVariables = array(
			'licensing_api',
			'license_product',
			'license_key',
			'license_token',
			'license_action',
		);
		$queryVariables = array_merge($queryVariables, $licensingVariables);
		return $queryVariables;
	}
}
