<?php
/**
 * @package   awf
 * @copyright Copyright (c)2014-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU GPL version 3 or later
 */

namespace Awf\Router;

use Awf\Container\Container;
use Awf\Container\ContainerAwareInterface;
use Awf\Container\ContainerAwareTrait;
use Awf\Uri\Uri;

class Router implements ContainerAwareInterface
{
	use ContainerAwareTrait;

	/**
	 * The routing rules for the application
	 *
	 * @var  array[Rule]
	 */
	protected $rules = array();

	/**
	 * Public constructor
	 *
	 * @param   Container $container The container this router is attached to
	 *
	 * @return  void
	 *
	 * @codeCoverageIgnore
	 */
	public function __construct(Container $container)
	{
		$this->setContainer($container);
	}

	/**
	 * Add a routing rule to the stack
	 *
	 * @param   Rule $rule The routing rule to add
	 *
	 * @return  void
	 *
	 * @codeCoverageIgnore
	 */
	public function addRule(Rule $rule)
	{
		$this->rules[] = $rule;
	}

	/**
	 * Add a routing rule to the stack from an array definition
	 *
	 * @param   array $definition The definition of the routing rule to add, @see Route::__construct()
	 *
	 * @return  void
	 *
	 * @codeCoverageIgnore
	 */
	public function addRuleFromDefinition(array $definition)
	{
		$rule = new Rule($definition);

		$this->addRule($rule);
	}

	/**
	 * Add multiple rules in one go. The array can contain either Rule objects or rule definitions in array format
	 *
	 * @param   array $rules The rules to add
	 *
	 * @return  void
	 *
	 * @codeCoverageIgnore
	 */
	public function addRules(array $rules)
	{
		if (!empty($rules))
		{
			foreach ($rules as $rule)
			{
				if (is_array($rule))
				{
					$this->addRuleFromDefinition($rule);

					continue;
				}

				if (!is_object($rule))
				{
					continue;
				}

				if (!($rule instanceof Rule))
				{
					continue;
				}

				$this->addRule($rule);
			}
		}
	}

	/**
	 * Clear all routing rules
	 *
	 * @return  void
	 *
	 * @codeCoverageIgnore
	 */
	public function clearRules()
	{
		$this->rules = array();
	}

	/**
	 * Put a URL through the routing rules and return the routed URL.
	 *
	 * @param   string  $url      The URL to route
	 * @param   boolean $rebase   Should I rebase the resulting URL? False to return a relative URL to the application's
	 *                            live_site and base_url, as defined in the application configuration
	 *
	 * @return  string  The routed URL
	 */
	public function route($url, $rebase = true)
	{
		// Initialise
		$routeResult = null;

		// Use the routing rules to produce a list of segments
		if (!empty($this->rules))
		{
			/** @var Rule $rule */
			foreach ($this->rules as $rule)
			{
				$routeResult = $rule->route($url);

				if (is_array($routeResult))
				{
					break;
				}
			}
		}

		// Parse the URL into an object
		$uri = new Uri($url);

		if (!is_null($routeResult))
		{
			// We'll replace the path and query string parameters with what the routing rule sent us
			$uri->setPath(implode('/', $routeResult['segments']));
			$uri->setQuery($routeResult['vars']);
		}

		// Do we have to rebase?
		if ($rebase)
		{
			// Get the base URL
			$baseUrl = $this->container->appConfig->get('base_url', '');

			if (empty($baseUrl))
			{
				$baseUrl = '';
			}

			$baseUrl = rtrim($baseUrl, '/');

			if ((strpos($baseUrl, 'http://') === 0) || (strpos($baseUrl, 'https://') === 0))
			{
				$base = $baseUrl;
			}
			else
			{
				$base = Uri::base(false, $this->container);
				$base = rtrim($base, '/') . '/' . $baseUrl;
			}
			$rebaseURI = new Uri($base);

			// Merge the paths of the rebase and routed URIs. However if the router URI contains index.php
			// and the base URL ends in a .php script do not append index.php to the other .php script
			// (this is required for the WP integration to work properly)
			if (!(($uri->getPath() == 'index.php') && (substr($rebaseURI->getPath(), -4) == '.php')))
			{
				$rebaseURI->setPath(rtrim($rebaseURI->getPath(), '/') . '/' . $uri->getPath());
			}

			// Merge the query string parameters of the rebase and routed URIs
			$vars_routed = $uri->getQuery(true);
			$vars_rebase = $rebaseURI->getQuery(true);
			$rebaseURI->setQuery(array_merge($vars_rebase, $vars_routed));

			// We'll return the rebased URI
			$uri = $rebaseURI;
		}

		/**
		 * WordPress: URL-encode the slash in wp-admin/admin.php?page=plugin/file.php&... URLs.
		 *
		 * When calling $uri->toString() the results of http_build_query() are passed through urldecode() first.
		 * However, we must preserve the encoded forward slash in the page parameter since some servers will throw a
		 * 403 if it's not encoded. Yeah, this is totally WP's fault, but we have to work around it with ugly code
		 * because WordPress is written as if it's still 2001. Bleh!
		 */
		if (
			defined('WPINC')
			&& substr($uri->getPath(), -10) === '/admin.php'
			&& $uri->hasVar('page')
			&& strpos($uri->getVar('page'), '/') !== false
		)
		{
			$uri->setVar('page', urlencode($uri->getVar('page')));
		}

		return $uri->toString();
	}

	/**
	 * Parse a routed URL based on the routing rules, setting the input variables of the attached application.
	 *
	 * @param   string  $url    The URL to parse. If omitted the current URL will be used.
	 * @param   boolean $rebase Is this a rebased URL? If false we assume we're given a relative URL.
	 */
	public function parse($url = null, $rebase = true)
	{
		// If we are not given a URL, use the current URL of the site
		if (empty($url))
		{
			$url = Uri::current();
		}

		// Initialise
		$removePath = null;
		$removeVars = null;

		if ($rebase)
		{
			// Get the base URL
			$baseUrl = $this->container->appConfig->get('base_url', '');

			if (empty($baseUrl))
			{
				$baseUrl = '';
			}

			$baseUrl = rtrim($baseUrl, '/');

			$base = Uri::base(false, $this->container);
			$base = rtrim($base, '/') . '/' . $baseUrl;
			$rebaseURI = new Uri($base);

			// Get the path and vars to remove from the parsed route
			$removePath = $rebaseURI->getPath();
			$removePath = trim($removePath, '/');
			$removeVars = $rebaseURI->getQuery(true);
		}

		$uri = new Uri($url);
		$path = $uri->getPath();
		$path = trim($path, '/');

		// Remove the $removePath
		if (!empty($removePath))
		{
			if (strpos($path, $removePath) === 0)
			{
				$path = substr($path, strlen($removePath));
			}
		}

		// Use the routing rules to parse the URL
		$routeVars = null;
		if (!empty($this->rules))
		{
			/** @var Rule $rule */
			foreach ($this->rules as $rule)
			{
				$routeVars = $rule->parse($path);

				if (is_array($routeVars))
				{
					break;
				}
			}
		}

		if (is_null($routeVars))
		{
			$routeVars = array();
		}

		// Mix route and URI vars
		$uriVars = $uri->getQuery(true);
		$routeVars = array_merge($routeVars, $uriVars);

		// Eliminate $removeVars
		if (is_array($removeVars) && !empty($removeVars))
		{
			foreach ($removeVars as $k => $v)
			{
				if (isset($routeVars[$k]) && ($routeVars[$k] == $v))
				{
					unset($routeVars[$k]);
				}
			}
		}

		// Set the query vars to the application
		if (is_array($routeVars) && !empty($routeVars))
		{
			foreach ($routeVars as $k => $v)
			{
				$this->container->input->set($k, $v);
			}
		}
	}

	/**
	 * Exports the routing maps as a JSON string
	 *
	 * @return  string  The routes in JSON format
	 */
	public function exportRoutes()
	{
		$maps = array();

		if (!empty($this->rules))
		{
			/** @var Rule $rule */
			foreach ($this->rules as $rule)
			{
				$maps[] = array(
					'path'          => $rule->getPath(),
					'types'         => $rule->getTypes(),
					'matchVars'     => $rule->getMatchVars(),
					'pushVars'      => $rule->getPushVars(),
					'routeCallable' => $rule->getRouteCallable(),
					'parseCallable' => $rule->getParseCallable(),
				);
			}
		}

		$options = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;

		return json_encode($maps, $options);
	}

	/**
	 * Imports routes from a JSON string
	 *
	 * @param   string  $json    The JSON string to parse
	 * @param   boolean $replace [optional] Should I replace existing routes?
	 *
	 * @return  void
	 */
	public function importRoutes($json, $replace = true)
	{
		$definitions = json_decode($json, true);

		if ($replace)
		{
			$this->clearRules();
		}

		$this->addRules($definitions);
	}
} 
