<?php

namespace EesyPHP;

use Exception;

class Url {

  /**
   * Current request
   * @var UrlRequest|null
   */
  public static $request = null;

  /**
   * Configured URL patterns :
   *
   *  Example :
   *
   *   array (
   *     '|get/(?P<name>[a-zA-Z0-9]+)$|' => array (
   *       'handler' => 'get',
   *       'additional_info' => array(),
   *       'authenticated' => true,
   *       'api_mode' => false,
   *       'methods' => array('GET'),
   *     ),
   *     '|get/all$|' => => array (
   *       'handler' => 'get_all',
   *       'additional_info' => array(),
   *       'authenticated' => true,
   *       'api_mode' => false,
   *       'methods' => array('GET', 'POST'),
   *     ),
   *   )
   * @var array
   */
  protected static $patterns = array();

  /**
   * Error 404 handler
   * @var callable
   */
  protected static $error_404_handler = array('\\EesyPHP\\Url', 'error_404');

  /**
   * Public root URL
   * @var string|null
   */
  protected static $public_root_url = null;

  /**
   * Enable/disable API mode
   * @see self :: initialization()
   * @var bool
   */
  protected static $_api_mode;

  /**
   * Initialization
   * @return void
   */
  public static function init() {
    // Set default config values
    App :: set_default('public_root_url', null);
    App :: set_default('api_mode', false);

    if (php_sapi_name() == 'cli-server' && getenv('EESYPHP_SERVE_URL'))
      $public_root_url = getenv('EESYPHP_SERVE_URL');
    else
      $public_root_url = App::get('public_root_url', null, 'string');
    if (is_string($public_root_url) && $public_root_url) {
      // Remove trailing slash
      $public_root_url = rtrim($public_root_url, '/');
      self :: $public_root_url = $public_root_url?$public_root_url:null;
    }
    self :: $_api_mode = boolval(App::get('api_mode', null, 'bool'));
  }

  /**
   * Add an URL pattern
   *
   * @param string|array|null $pattern          The URL pattern, an array of patterns or null to
   *                                            match to the root of the application
   * @param callable          $handler          The URL pattern handler (must be callable, required)
   * @param array|null        $additional_info  Array of information to pass to the URL handler
   * @param boolean           $authenticated    Permit to define if this URL is accessible only for
   *                                            authenticated users (optional, default: true if the
   *                                            EesyPHP Authentication feature is enabled, false
   *                                            otherwise)
   * @param boolean           $overwrite        Allow overwrite if a command already exists with the
   *                                            same name (optional, default: false)
   * @param boolean           $api_mode         Enable API mode (optional, default: false)
   * @param array|string|null $http_methods     HTTP method (optional, default: array('GET', 'POST'))
   * @return bool
   **/
  public static function add_url_handler($pattern, $handler=null, $additional_info=null,
                                         $authenticated=null, $overwrite=true, $api_mode=false,
                                         $http_methods=null) {
    // Check HTTP methods parameter
    if (is_null($http_methods))
      $http_methods = array('GET', 'POST');
    elseif (!is_array($http_methods))
      $http_methods = array($http_methods);

    // If multiple patterns specify using an array, add each of them
    if (is_array($pattern)) {
      $error = false;
      if (is_null($handler))
        foreach($pattern as $p => $h)
          if (
            !self :: add_url_handler(
              $p, $h, $additional_info, $authenticated, $overwrite, $api_mode, $http_methods)
          ) $error = true;
      else
        foreach($pattern as $p)
          if (
            !self :: add_url_handler(
              $p, $handler, $additional_info, $authenticated, $overwrite, $api_mode, $http_methods)
          ) $error = true;
      return !$error;
    }
    else if (is_null($pattern))
      $pattern = '#^$#';

    $error = false;
    foreach($http_methods as $http_method) {
      if (!isset(self :: $patterns[$http_method]))
          self :: $patterns[$http_method] = array();
      // Check overwrite
      if (isset(self :: $patterns[$http_method][$pattern])) {
          if (!$overwrite) {
              Log :: error(
                  "URL : pattern '%s' already defined for HTTP method %s: do not overwrite.".
                  $pattern, $http_method);
              $error = true;
              continue;
          }
          Log :: debug(
              "URL : overwrite pattern '%s' for HTTP method %s with handler '%s' ".
              "(old handler = '%s')",
              $pattern, $http_method, format_callable($handler),
              vardump(self :: $patterns[$http_method][$pattern])
          );
      }

      // Register URL pattern info
      self :: $patterns[$http_method][$pattern] = array(
          'handler' => $handler,
          'additional_info' => (
              is_array($additional_info)?
              $additional_info:array()
          ),
          'authenticated' => $authenticated,
          'api_mode' => $api_mode,
      );
      Log :: trace(
          "URL: Register pattern '%s' on HTTP %s with :\n%s",
          $pattern, $http_method, vardump(self :: $patterns[$http_method][$pattern])
      );
    }

    return !$error;
  }

  /**
   * Show error page
   *
   * @param $request UrlRequest|null The request (optional, default: null)
   * @param $error_code int|null The HTTP error code (optional, default: 400)
   *
   * @return never
   **/
  public static function error_page($request=null, $error_code=null) {
    $http_errors = array(
      400 => array(
        'pagetitle' => I18n::_("Bad request"),
        'message' => I18n::_("Invalid request."),
      ),
      401 => array(
        'pagetitle' => I18n::_("Authentication required"),
        'message' => I18n::_("You have to be authenticated to access to this page."),
      ),
      403 => array(
        'pagetitle' => I18n::_("Access denied"),
        'message' => I18n::_("You do not have access to this application. If you think this is an error, please contact support."),
      ),
      404 => array(
        'pagetitle' => I18n::_("Whoops ! Page not found"),
        'message' => I18n::_("The requested page can not be found."),
      ),
    );
    $error_code = ($error_code?intval($error_code):400);
    if (array_key_exists($error_code, $http_errors))
      $error = $http_errors[intval($error_code)];
    else
      $error = array(
        'pagetitle' => I18n::_('Error'),
        'message' => I18n::_('An unknown error occurred. If problem persist, please contact support.'),
      );
    http_response_code($error_code);

    if (Tpl :: initialized()) {
      Tpl :: assign('message', $error['message']);
      Tpl :: display('error_page.tpl', $error['pagetitle']);
      exit();
    }
    die($error['message']);
  }

   /**
    * Error 404 handler
    *
    * @param UrlRequest|null $request The request (optional, default: null)
    *
    * @return never
    **/
  public static function error_404($request=null) {
    self :: error_page($request, 404);
  }

  /**
   * Set unknown URL handler
   * @param callable|null $handler
   * @return bool
   */
  public static function set_unknown_url_handler($handler=null) {
    // @phpstan-ignore-next-line
    if (is_callable($handler) || is_null($handler)) {
      self :: $error_404_handler = $handler;
      return true;
    }
    // @phpstan-ignore-next-line
    Log :: warning(
      'Url::set_unknown_url_handler(): Invalid URL handler provided: %s',
      vardump($handler));
    return false;
  }

  /**
   * Trigger a 404 HTTP error
   * @param UrlRequest|null $request Current UrlRequest object (optional, default: null)
   * @return never
   */
  public static function trigger_error_404($request=null) {
    call_user_func_array(self :: $error_404_handler, array($request));
    exit();
  }


  /**
   * Interprets the requested URL and return the corresponding UrlRequest object
   *
   * @param $default_url string|null The default URL if current one does not
   *                                 match with any configured pattern.
   *
   * @return UrlRequest The UrlRequest object corresponding to the the requested URL.
   */
  protected static function get_request($default_url=null) {
    $current_url = self :: get_current_url();
    if ($current_url === false)
      Log :: fatal(
        I18n::_('Unable to determine the requested page. '.
        'If the problem persists, please contact support.')
      );
    if (!is_array(self :: $patterns))
      Log :: fatal('URL : No URL patterns configured !');

    Log :: debug("URL : current url = '$current_url'");
    if (array_key_exists($_SERVER['REQUEST_METHOD'], self :: $patterns)) {
      Log :: trace(
        "URL : check current url with the following URL patterns :\n - ".
        implode("\n - ", array_keys(self :: $patterns))
      );
      foreach (self :: $patterns[$_SERVER['REQUEST_METHOD']] as $pattern => $handler_infos) {
        $m = self :: url_match($pattern, $current_url);
        if (is_array($m)) {
          $request = new UrlRequest($current_url, $handler_infos, $m);
          // Reset last redirect
          if (isset($_SESSION['last_redirect']))
            unset($_SESSION['last_redirect']);
          Log :: trace("URL : result :\n".vardump($request));
          return $request;
        }
      }
    }
    else {
      Log :: debug(
        'URL: no URL pattern registered for %s HTTP method',
        $_SERVER['REQUEST_METHOD']);
    }
    if ($default_url !== false) {
      Log :: debug("Current url match with no pattern. Redirect to default url ('$default_url')");
      self :: redirect($default_url);
    }
    // Error 404
    $api_mode = self :: $_api_mode || (strpos($current_url, 'api/') === 0);
    Log :: debug(
      "Current URL match with no pattern. Use error 404 handler (API mode=$api_mode).");
    return new UrlRequest(
      $current_url,
      array(
        'handler' => self :: $error_404_handler,
        'authenticated' => false,
        'api_mode' => $api_mode,
      )
    );
  }

  /**
   * Check if the current requested URL match with a specific pattern
   *
   * @param string $pattern The URL pattern
   * @param string|false $current_url The current URL (optional)
   *
   * @return array|false The URL info if pattern matched, false otherwise.
   **/
  public static function url_match($pattern, $current_url=false) {
    if ($current_url === false) {
      $current_url = self :: get_current_url();
      if (!$current_url) return False;
    }
    if (preg_match($pattern, $current_url, $m)) {
      Log :: debug(
        "URL : Match found with pattern '$pattern' :\n\t".
        str_replace("\n", "\n\t", print_r($m, true)));
      return $m;
    }
    return False;
  }

  /**
   * Retreive current requested URL and return it
   *
   * @return string|false The current request URL or false if fail
   **/
  public static function get_current_url() {
    Log :: trace("URL : request URI = '".$_SERVER['REQUEST_URI']."'");

    $base = self :: get_rewrite_base();
    Log :: trace("URL : rewrite base = '$base'");

    if ($_SERVER['REQUEST_URI'] == $base)
      return '';

    if (substr($_SERVER['REQUEST_URI'], 0, strlen($base)) != $base) {
      Log :: error(
        "URL : request URI (".$_SERVER['REQUEST_URI'].") does not start with rewrite base ($base)");
      return False;
    }

    $current_url = substr($_SERVER['REQUEST_URI'], strlen($base));

    // URL contain params ?
    $params_start = strpos($current_url, '?');
    if ($params_start !== false)
      // Params detected, remove it

      // No url / currrent url start by '?' ?
      if ($params_start == 0)
        return '';
      else
        return substr($current_url, 0, $params_start);

    return $current_url;
  }

  /**
   * Try to detect rewrite base from public root URL
   *
   * @return string The detected rewrite base
   **/
  public static function get_rewrite_base() {
    if (!self :: $public_root_url) return '/';
    if (preg_match('|^https?://[^/]+/(.*)$|', self :: $public_root_url, $m))
      return '/'.self :: remove_trailing_slash($m[1]).'/';
    if (preg_match('|^/(.*)$|', self :: $public_root_url, $m))
      return '/'.self :: remove_trailing_slash($m[1]).'/';
    return '/';
  }

  /**
   * Trigger redirect to specified URL (or homepage if omited)
   *
   * @param string|false $go The destination URL
   *
   * @return never
   **/
  public static function redirect($go=false) {
    if ($go===false)
      $go = "";
    // If more than one argument passed, format URL using sprintf & urlencode parameters
    elseif (func_num_args() > 1)
      $go = call_user_func_array(
        'sprintf',
        array_merge(
          array($go),
          array_map('urlencode', array_slice(func_get_args(), 1))
        )
      );

    if (self :: is_absolute_url($go))
      $url = $go;
    elseif (self :: $public_root_url)
      $url = self :: $public_root_url."/$go";
    else
      $url = "/$go";

    // Prevent loop
    if (isset($_SESSION['last_redirect']) && $_SESSION['last_redirect'] == $url)
      Log :: fatal(
        I18n::_('Unable to determine the requested page (loop detected). '.
        'If the problem persists, please contact support.'));
    $_SESSION['last_redirect'] = $url;

    Log :: debug("redirect($go) => Redirect to : <$url>");
    header("Location: $url");
    exit();
  }

  /**
   * Handle the current requested URL
   *
   * Note: if the route required that user is authenticated, this method will
   * invoke Auth::login() in force mode (or trigger a fatal error if fail).
   *
   * @param string|null $default_url The default URL if current one does not
   *                                 match with any configured pattern.
   *
   * @return void
   **/
  public static function handle_request($default_url=null) {
    $sentry_span = new SentrySpan('http.handle_request', 'Handle the HTTP request');

    self :: $request = self :: get_request($default_url);

    if (!is_callable(self :: $request  -> handler)) {
      Log :: error(
        "URL handler function %s does not exists !",
        format_callable(self :: $request  -> handler));
      Log :: fatal(I18n::_("This request cannot be processed."));
    }

    if (self :: $request  -> api_mode)
      self :: $_api_mode = true;
    if (Tpl :: initialized())
      Tpl :: assign('request', self :: $request );

    // Check authentication (if need)
    if(self :: $request  -> authenticated && !Auth::login(true))
      Log :: fatal(I18n::_("Authentication required but fail to authenticate you."));

    Hook::trigger('before_handling_request', array('request' => self :: $request));
    $success = false;
    try {
      call_user_func(self :: $request  -> handler, self :: $request);
      $success = true;
    }
    catch (Exception $e) {
      Log :: exception(
        $e, "An exception occured running URL handler %s",
        format_callable(self :: $request  -> handler));
    }
    Hook::trigger(
      'after_handling_request',
      array('request' => self :: $request, 'success' => $success)
    );
    $sentry_span->finish();
    if (!$success)
      Log :: fatal(I18n::_("This request could not be processed correctly."));
  }

  /**
   * Remove trailing slash in specified URL
   *
   * @param string $url The URL
   *
   * @return string The specified URL without trailing slash
   **/
  public static function remove_trailing_slash($url) {
    if ($url == '/')
      return $url;
    return rtrim($url, '/');
  }

  /**
   * Get the public absolute URL
   *
   * @param string|null $relative_url Relative URL to convert (Default: current URL)
   *
   * @return string The public absolute URL
   **/
  public static function get_absolute_url($relative_url=null) {
    if (!is_string($relative_url))
        $relative_url = self :: get_current_url();
    if (self :: $public_root_url[0] == '/') {
        Log :: debug(
          "URL :: get_absolute_url($relative_url): configured public root URL is relative ".
          "(%s) => try to detect it from current request infos.",
          self :: $public_root_url);
        self :: $public_root_url = (
          'http'.(isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on'?'s':'').'://'.
          $_SERVER['HTTP_HOST'].self :: $public_root_url);
        Log :: debug(
          "URL :: get_absolute_url(%s): detected public_root_url: %s",
          $relative_url, self :: $public_root_url);
    }

    if (substr($relative_url, 0, 1) == '/')
      $relative_url = substr($relative_url, 1);
    $url = self :: remove_trailing_slash(self :: $public_root_url)."/$relative_url";
    Log :: debug("URL :: get_absolute_url($relative_url): result = $url");
    return $url;
  }

  /**
   * Check if specified URL is absolute
   *
   * @param string $url The URL to check
   *
   * @return boolean True if specified URL is absolute, False otherwise
   **/
  public static function is_absolute_url($url) {
    return boolval(preg_match('#^https?://#', $url));
  }

  /**
   * Add parameter in specified URL
   *
   * @param string $url The URL
   * @param string $parameter The parameter name
   * @param string $value The parameter value
   * @param boolean $encode Set if parameter value must be URL encoded (optional, default: true)
   *
   * @return string The completed URL
   */
  public static function add_url_parameter($url, $parameter, $value, $encode=true) {
    if (strpos($url, '?') === false)
      $url .= '?';
    else
      $url .= '&';
    $url .= "$parameter=".($encode?urlencode($value):$value);
    return $url;
  }

  /**
   * Add parameters in specified URL
   *
   * @param string $url The URL
   * @param array<string,string> $parameters The parameters as an associative array
   * @param boolean $encode Set if parameters values must be URL encoded (optional, default: true)
   *
   * @return string The completed URL
   */
  public static function add_url_parameters($url, $parameters, $encode=true) {
    foreach($parameters as $parameter => $value)
      $url = self :: add_url_parameter($url, $parameter, $value, $encode);
    return $url;
  }

  /**
   * Get/set API mode
   * @param bool|null $value If boolean, the current API mode will be changed
   * @return bool Current API mode
   */
  public static function api_mode($value=null) {
    if (is_bool($value)) self :: $_api_mode = $value;
    return self :: $_api_mode;
  }

  /**
   * Get public root URL
   * @return string
   */
  public static function public_root_url() {
    return (self :: $public_root_url?self :: $public_root_url:'/');
  }
}

# vim: tabstop=2 shiftwidth=2 softtabstop=2 expandtab
