<?php

/**
 * Clase base para la gestión de formularios.
 *
 * Gestión de token CSRF está basada en: https://www.owasp.org/index.php/PHP_CSRF_Guard
 */
abstract class Form {

    /**
     * @var string Sufijo para el nombre del parámetro de la sesión del usuario donde se almacena el token CSRF.
     */
    const CSRF_PARAM = 'csrf';

    /**
     * @var string Identificador utilizado para construir el atributo "id" de la etiqueta &lt;form&gt; como <code>$tipoFormulario.$formId</code>.
     */
    private $formId;

    /**
     * @var string Valor del parámetro enctype del formulario.
     */
    private $enctype;

    /**
     * @var string Valor del atributo "class" de la etiqueta &lt;form&gt; asociada al formulario. Si este parámetro incluye la cadena "nocsrf" no se generá el token CSRF para este formulario.
     */
    private $classAtt;

     /**
     * @var string Parámetro de la petición utilizado para comprobar que el usuario ha enviado el formulario..
     */
    private $tipoFormulario;

    /**
     * @var string URL asociada al atributo "action" de la etiqueta &lt;form&gt; del fomrulario y que procesará el 
     * envío del formulario.
     */
    private $action;
    private $printed;

     /**
     * @var bool Almacena si la interacción con el formulario va a realizarse a través de AJAX <code>true</code> o
     * <code>false</code> en otro caso.
     */
    private $ajax;

    /**
     * Crea un nuevo formulario.
     *
     * Posibles opciones:
     * <table>
     *   <thead>
     *     <tr>
     *       <th>Opción</th>
     *       <th>Valor por defecto</th>
     *       <th>Descripción</th>
     *     </tr>
     *   </thead>
     *   <tbody>
     *     <tr>
     *       <td>action</td>
     *       <td><code>$_SERVER['PHP_SELF']</code></td>       
     *       <td>URL asociada al atributo "action" de la etiqueta &lt;form&gt; del fomrulario y que procesará el envío del formulario.</td>
     *     </tr>
     *     <tr>
     *        <td>class</td>
     *        <td>""</td>       
     *        <td>Valor del atributo "class" de la etiqueta &lt;form&gt; asociada al formulario. Si este parámetro incluye la cadena
     *        "nocsrf" no se generá el token CSRF para este formulario.</td>
     *     </tr>
     *      <tr>
     *       <td>enctype</td>
     *       <td>""</td>       
     *       <td>Valor del parámetro enctype del formulario.</td>
     *     </tr>
     *     <tr>
     *       <td>ajax</td>
     *       <td><code>false</code></td>       
     *       <td>Configura si el formulario se gestionará a través de AJAX.</td>
     *     </tr>
     *   </tbody>
     * </table>
     * @param string $tipoFormulario Parámetro de la petición utilizado para comprobar que el usuario ha enviado el formulario.
     * @param string $formId (opcional) Identificador utilizado para construir el atributo "id" de la etiqueta &lt;form&gt; como <code>$tipoFormulario.$formId</code>. 
     *
     * @param array $opciones (ver más arriba).
     */
    public function __construct($tipoFormulario, $opciones = array(), $formId = 1)
    {
        $this->tipoFormulario = $tipoFormulario;
        $this->formId = $tipoFormulario.$formId;

        $opcionesPorDefecto = array( 'ajax' => false, 'action' => null, 'class' => null, 'enctype' => null );
        $opciones = array_merge($opcionesPorDefecto, $opciones);

        $this->ajax     = $opciones['ajax'];
        $this->action   = $opciones['action'];
        $this->classAtt = $opciones['class'];
        $this->enctype  = $opciones['enctype'];
        
        if ( !$this->action ) {
            // Cambiar por << $this->action = htmlentities($_SERVER['REQUEST_URI']); >> para mantener los parámetros de la URL.
            $this->action = htmlentities($_SERVER['PHP_SELF']);
        }
    }
  
    /**
     * Se encarga de orquestar todo el proceso de gestión de un formulario.
     * 
     * El proceso es el siguiente:
     * <ul>
     *   <li>O bien se quiere mostrar el formulario (petición GET)</li>
     *   <li>O bien hay que procesar el formulario (petición POST) y hay dos situaciones:
     *     <ul>
     *       <li>El formulario se ha procesado correctamente y se devuelve un <code>string</code> en {@see Form::procesaFormulario()}
     *           que será la URL a la que se rederigirá al usuario. Se redirige al usuario y se termina la ejecución del script.</li>
     *       <li>El formulario NO se ha procesado correctamente (errores en los datos, datos incorrectos, etc.) y se devuelve
     *           un <code>array</code> con entradas (campo, mensaje) con errores específicos para un campo o (entero, mensaje) si el mensaje
     *           es un mensaje que afecta globalmente al formulario. Se vuelve a generar el formulario pasándole el array de errores.</li> 
     *     </ul>
     *   </li>
     * </ul>
     */
    public function gestiona()
    {
        if ( ! $this->formularioEnviado($_POST) ) {
            return $this->generaFormulario();
        } else {
            // Valida el token CSRF si es necesario (hay un token en la sesión asociada al formulario)
            $tokenRecibido = $_POST['CSRFToken'] ?? FALSE;
            $errores = $this->csrfguard_ValidateToken($this->tipoFormulario, $tokenRecibido);

            // limpia los tokens CSRF que no han sido utilizados en esta petición
            self::limpiaCsrfTokens();

            // Sin AJAX.
            /**
             * $result = $this->procesaFormulario($_POST);
             * if ( is_array($result) ) {
             *      return $this->generaFormulario($_POST, $result);
             * } else {
             *      header('Location: '.$result);
             *      exit();
             * }
             */
            
            // Con AJAX.
            if ( $errores !== TRUE ) { 
                if ( ! $this->ajax ) {
                    return $this->generaFormulario($_POST, $errores);
                } else {
                    return $this->generaHtmlErrores($errores);
                }
            } else  {
                $result = $this->procesaFormulario($_POST);
                if ( is_array($result) ) {
                    // Error al procesar el formulario, volvemos a mostrarlo
                    if ( ! $this->ajax ) {
                        return $this->generaFormulario($_POST, $result);
                    } else {
                        return $this->generaHtmlErrores($result);
                    }
                } else {
                    if ( ! $this->ajax ) {
                        header('Location: '.$result);
                        exit();
                    } else {
                        return $result;
                    }
                }
            }
        
        }
    }

    /**
     * Genera el HTML necesario para presentar los campos del formulario.
     * 
     * Si el formulario ya ha sido enviado y hay errores en {@see Form::procesaFormulario()} se llama a este método
     * nuevamente con los datos que ha introducido el usuario en <code>$datosIniciales</code> y los errores al procesar
     * el formulario en <code>$errores</code>
     *
     * @param string[] $datosIniciales Datos iniciales para los campos del formulario (normalmente <code>$_POST</code>).
     *
     * @param string[] $errores (opcional)Lista / Tabla asociativa de errores asociados al formulario.
     * 
     * @return string HTML asociado a los campos del formulario.
     */
    protected function generaCamposFormulario($datosIniciales, $errores = array())
    {
        return '';
    }

    /**
     * Procesa los datos del formulario.
     *
     * @param string[] $datos Datos enviado por el usuario (normalmente <code>$_POST</code>).
     *
     * @return string|string[] Devuelve el resultado del procesamiento del formulario, normalmente una URL a la que
     * se desea que se redirija al usuario, o un array con los errores que ha habido durante el procesamiento del formulario.
     */
    protected function procesaFormulario($datos)
    {
        return array();
    }
  
    /**
     * Función que verifica si el usuario ha enviado el formulario.
     * 
     * Comprueba si existe el parámetro <code>$formId</code> en <code>$params</code>.
     *
     * @param string[] $params Array que contiene los datos recibidos en el envío formulario.
     *
     * @return boolean Devuelve <code>true</code> si <code>$formId</code> existe como clave en <code>$params</code>
     */
    private function formularioEnviado(&$params)
    {
        return isset($params['action']) && $params['action'] == $this->tipoFormulario;
    } 

    /**
     * Función que genera el HTML necesario para el formulario.
     *
     * @param string[] $datos (opcional) Array con los valores por defecto de los campos del formulario.
     *
     * @param string[] $errores (opcional) Array con los mensajes de error de validación y/o procesamiento del formulario.
     *
     * @return string HTML asociado al formulario.
     */
    private function generaFormulario(&$datos = array(), &$errores = array())
    {
        $htmlCamposFormularios = $this->generaCamposFormulario($datos, $errores);

        $classAtt='';
        if ( $this->classAtt ) {
          $classAtt = " class=\"{$this->classAtt}\"";
        }

        $enctypeAtt='';
        if ( $this->enctype ) {
            $enctypeAtt = " enctype=\"{$this->enctype}\"";
        }

        // Se genera el token CSRF si el usuario no solicita explícitamente lo contrario.
        $tokenCSRF = '';
        if ( ! $this->classAtt || strpos($this->classAtt, 'nocsrf') === false ) {
            $tokenValue = $this->csrfguard_GenerateToken($this->tipoFormulario);
            $tokenCSRF = "<input type='hidden' name='CSRFToken' value='$tokenValue' />";
        }

        /* <<< Permite definir cadena en múltiples líneas.
         * Revisa https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.heredoc
         */
        $htmlForm = "<form method='POST' action='{$this->action}' id='{$this->formId}'{$classAtt}{$enctypeAtt} >
                        <input type='hidden' name='action' value='{$this->tipoFormulario}' />
                        ".$tokenCSRF.$htmlCamposFormularios."
                    </form>";
        return $htmlForm;
    }

    /**
     * Genera la lista de mensajes de errores globales (no asociada a un campo) a incluir en el formulario.
     *
     * @param string[] $errores (opcional) Array con los mensajes de error de validación y/o procesamiento del formulario.
     *
     * @param string $classAtt (opcional) Valor del atributo class de la lista de errores.
     *
     * @return string El HTML asociado a los mensajes de error.
     */
    protected static function generaListaErroresGlobales($errores = array(), $classAtt='')
    {
        $html='';
        $clavesErroresGenerales = array_filter(array_keys($errores), function ($elem) {
            return is_numeric($elem);
        });

        $numErrores = count($clavesErroresGenerales);
        if ($numErrores > 0) {
            $html = "<ul class=\"$classAtt\">";
            if (  $numErrores == 1 ) {
                $html .= "<li>$errores[0]</li>";
            } else {
                foreach($clavesErroresGenerales as $clave) {
                    $html .= "<li>$errores[$clave]</li>";
                }
                $html .= "</li>";
            }
            $html .= '</ul>';
        }
        return $html;
    }

    /**
     * Crea una etiqueta para mostrar un mensaje de error. Sólo creará el mensaje de error
     * si existe una clave <code>$idError</code> dentro del array <code>$errores</code>.
     * 
     * @param string[] $errores     (opcional) Array con los mensajes de error de validación y/o procesamiento del formulario.
     * @param string   $idError     (opcional) Clave dentro de <code>$errores</code> del error a mostrar.
     * @param string   $htmlElement (opcional) Etiqueta HTML a crear para mostrar el error.
     * @param array    $atts        (opcional) Tabla asociativa con los atributos a añadir a la etiqueta que mostrará el error.
     */
    protected static function createMensajeError($errores=array(), $idError='', $htmlElement='span', $atts = array())
    {
        $html = '';
        if (isset($errores[$idError])) {
            $att = '';
            foreach($atts as $key => $value) {
                $att .= "$key=$value";
            }
            $html = " <$htmlElement $att>{$errores[$idError]}</$htmlElement>";
        }

        return $html;
    }

    /**
     * Método para eliminar los tokens CSRF almecenados en la petición anterior que no hayan sido utilizados en la actual.
     */
    public static function limpiaCsrfTokens()
    {
        foreach(array_keys($_SESSION) as $key) {
            if (strpos($key, self::CSRF_PARAM) === 0) {
                unset($_SESSION[$key]);
            }
        }
    }

    private function csrfguard_GenerateToken($formParameter)
  {
    if ( ! session_id() ) {
      throw new \Exception('La sesión del usuario no está definida.');
    }

    $paramSession = self::CSRF_PARAM.'_'.$formParameter;
    if (isset($_SESSION[$paramSession])) {
      $token = $_SESSION[$paramSession];
    } else {
      if ( function_exists('hash_algos') && in_array('sha512', hash_algos()) ) {
        $token = hash('sha512', mt_rand(0, mt_getrandmax()));
      } else {
        $token=' ';
        for ($i=0;$i<128;++$i) {
          $r=mt_rand(0,35);
          if ($r<26){
            $c=chr(ord('a')+$r);
          } else{ 
            $c=chr(ord('0')+$r-26);
          } 
          $token.=$c;
        }
      }

      $_SESSION[$paramSession]=$token;
    }
    return $token;
  }

  private function csrfguard_ValidateToken($formParameter, $tokenRecibido)
  {
    if ( ! session_id() ) {
        throw new \Exception('La sesión del usuario no está definida.');
    }
    
    $result = TRUE;
    
    $paramSession = self::CSRF_PARAM.'_'.$formParameter;
    if ( isset($_SESSION[$paramSession]) ) {
        if ( $_SESSION[$paramSession] !== $tokenRecibido ) {
            $result = array();
            $result[] = 'Has enviado el formulario dos veces';
        }
        $_SESSION[$paramSession] = ' ';
        unset($_SESSION[$paramSession]);
    } else {
        $result = array();
        $result[] = 'Formulario no válido';
    }
        return $result;
  }

    //Test some form input.
    protected function test_input($input){
        return htmlspecialchars(trim(strip_tags($input)));
    }

}