<?php
/**
 * Rest controller class.
 *
 * @since 5.25
 */

namespace CiviCRM_WP_REST\Controller;

class Rest extends Base {

  /**
   * @var string
   * The base route.
   * @since 5.25
   */
  protected $rest_base = 'rest';

  /**
   * Registers routes.
   *
   * @since 5.25
   */
  public function register_routes() {

    register_rest_route($this->get_namespace(), $this->get_rest_base(), [
      [
        'methods' => \WP_REST_Server::ALLMETHODS,
        'callback' => [$this, 'get_items'],
        'permission_callback' => [$this, 'permissions_check'],
        'args' => $this->get_item_args(),
      ],
      'schema' => [$this, 'get_item_schema'],
    ]);

  }

  /**
   * Check get permission.
   *
   * @since 5.25
   *
   * @param WP_REST_Request $request
   * @return bool
   */
  public function permissions_check($request) {

    /**
     * Opportunity to bypass CiviCRM's authentication ('api_key' and 'site_key').
     *
     * Return 'true' or 'false' to grant or deny access to this endpoint.
     *
     * To deny and throw an error, return either a string, an array, or a \WP_Error.
     *
     * NOTE: if you use your own authentication, you still must log in the user
     * in order to respect/apply CiviCRM ACLs.
     *
     * @since 5.25
     *
     * @param null|bool|string|array|\WP_Error $grant_auth Grant, deny, or error
     * @param \WP_REST_Request $request The request
     */
    $grant_auth = apply_filters('civi_wp_rest/controller/rest/permissions_check', NULL, $request);

    if (is_bool($grant_auth)) {

      return $grant_auth;

    }

    elseif (is_string($grant_auth)) {

      return $this->civi_rest_error($grant_auth);

    }

    elseif (is_array($grant_auth)) {

      return $this->civi_rest_error(__('CiviCRM WP REST permission check error.', 'civicrm'), $grant_auth);

    }

    elseif ($grant_auth instanceof \WP_Error) {

      return $grant_auth;

    }

    else {

      if (!$this->is_valid_api_key($request)) {
        return $this->civi_rest_error(__('Param api_key is not valid.', 'civicrm'));
      }

      if (!$this->is_valid_site_key()) {
        return $this->civi_rest_error(__('Param key is not valid.', 'civicrm'));
      }

      return TRUE;

    }

  }

  /**
   * Get items.
   *
   * @since 5.25
   *
   * @param WP_REST_Request $request
   */
  public function get_items($request) {

    /**
     * Filter formatted API params.
     *
     * @since 5.25
     *
     * @param array $params
     * @param WP_REST_Request $request
     */
    $params = apply_filters('civi_wp_rest/controller/rest/api_params', $this->get_formatted_api_params($request), $request);

    try {
      $items = civicrm_api3(...$params);
    }
    catch (\CiviCRM_API3_Exception $e) {
      $items = $this->civi_rest_error($e);
    }

    if (!isset($items) || empty($items)) {
      return rest_ensure_response([]);
    }

    /**
     * Filter CiviCRM API result.
     *
     * @since 5.25
     *
     * @param array $items
     * @param WP_REST_Request $request
     */
    $data = apply_filters('civi_wp_rest/controller/rest/api_result', $items, $params, $request);

    // Only collections of items, ie any action but 'getsingle'.
    if (isset($data['values'])) {

      $data['values'] = array_reduce($items['values'] ?? $items, function($items, $item) use ($request) {

        $response = $this->prepare_item_for_response($item, $request);

        $items[] = $this->prepare_response_for_collection($response);

        return $items;

      }, []);

    }

    $response = rest_ensure_response($data);

    // Check whether we need to serve xml or json.
    if (!in_array('json', array_keys($request->get_params()))) {

      /**
       * Adds our response holding CiviCRM data before dispatching.
       *
       * @since 5.25
       *
       * @param WP_HTTP_Response $result Result to send to client
       * @param WP_REST_Server $server The REST server
       * @param WP_REST_Request $request The request
       * @return WP_HTTP_Response $result Result to send to client
       */
      add_filter('rest_post_dispatch', function($result, $server, $request) use ($response) {

        return $response;

      }, 10, 3);

      // serve xml
      add_filter('rest_pre_serve_request', [$this, 'serve_xml_response'], 10, 4);

    }

    else {

      // return json
      return $response;

    }

  }

  /**
   * Get formatted API params.
   *
   * @since 5.25
   *
   * @param WP_REST_Resquest $request
   * @return array $params
   */
  public function get_formatted_api_params($request) {

    $args = $request->get_params();

    $entity = $args['entity'];
    $action = $args['action'];

    // unset unnecessary args
    unset($args['entity'], $args['action'], $args['key'], $args['api_key']);

    if (!isset($args['json']) || is_numeric($args['json'])) {

      $params = $args;

    }

    else {

      $params = is_string($args['json']) ? json_decode($args['json'], TRUE) : [];

    }

    // Ensure check permissions is enabled.
    $params['check_permissions'] = TRUE;

    return [$entity, $action, $params];

  }

  /**
   * Matches the item data to the schema.
   *
   * @since 5.25
   *
   * @param object $item
   * @param WP_REST_Request $request
   */
  public function prepare_item_for_response($item, $request) {

    return rest_ensure_response($item);

  }

  /**
   * Serves XML response.
   *
   * @since 5.25
   *
   * @param bool $served Whether the request has already been served
   * @param WP_REST_Response $result
   * @param WP_REST_Request $request
   * @param WP_REST_Server $server
   */
  public function serve_xml_response($served, $result, $request, $server) {

    // Get XML from response.
    $xml = $this->get_xml_formatted_data($result->get_data());

    // Set content type header.
    $server->send_header('Content-Type', 'text/xml');

    echo $xml;

    return TRUE;

  }

  /**
   * Formats CiviCRM API result to XML.
   *
   * @since 5.25
   *
   * @param array $data The CiviCRM API result.
   * @return string $xml The formatted XML.
   */
  protected function get_xml_formatted_data(array $data) {

    // XML document.
    $xml = new \DOMDocument();

    // Result set element <ResultSet>.
    $result_set = $xml->createElement('ResultSet');

    // The xmlns:xsi attribute.
    $result_set->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');

    // Count attributes.
    if (isset($data['count'])) {
      $result_set->setAttribute('count', $data['count']);
    }

    // Build result from result => values.
    if (isset($data['values'])) {

      array_map(function($item) use ($result_set, $xml) {

        // Result element <Result>.
        $result = $xml->createElement('Result');

        // Format item.
        $result = $this->get_xml_formatted_item($item, $result, $xml);

        // Append result to result set.
        $result_set->appendChild($result);

      }, $data['values']);

    }
    else {

      // Result element <Result>.
      $result = $xml->createElement('Result');

      // Format item.
      $result = $this->get_xml_formatted_item($data, $result, $xml);

      // Append result to result set.
      $result_set->appendChild($result);

    }

    // Append result set.
    $xml->appendChild($result_set);

    return $xml->saveXML();

  }

  /**
   * Formats a single API result to XML.
   *
   * @since 5.25
   *
   * @param array $item The single API result.
   * @param \DOMElement $parent The parent element to append to.
   * @param \DOMDocument $doc The document.
   * @return \DOMElement $parent The parent element.
   */
  public function get_xml_formatted_item(array $item, \DOMElement $parent, \DOMDocument $doc) {

    // Build field => values.
    array_map(function($field, $value) use ($parent, $doc) {

      // Entity field element.
      $element = $doc->createElement($field);

      // Handle array values.
      if (is_array($value)) {

        array_map(function($key, $val) use ($element, $doc) {

          /*
           * Child element - append underscore '_' otherwise createElement will
           * throw an Invalid character exception as elements cannot start with
           * a number.
           */
          $child = $doc->createElement('_' . $key, $val);

          // Append child.
          $element->appendChild($child);

        }, array_keys($value), $value);

      }
      else {

        // Assign value.
        $element->nodeValue = $value;

      }

      // Append element.
      $parent->appendChild($element);

    }, array_keys($item), $item);

    return $parent;

  }

  /**
   * Item schema.
   *
   * @since 5.25
   *
   * @return array $schema
   */
  public function get_item_schema() {

    return [
      '$schema' => 'http://json-schema.org/draft-04/schema#',
      'title' => 'civicrm/v3/rest',
      'description' => __('CiviCRM API3 WP rest endpoint wrapper', 'civicrm'),
      'type' => 'object',
      'required' => ['entity', 'action', 'params'],
      'properties' => [
        'is_error' => [
          'type' => 'integer',
        ],
        'version' => [
          'type' => 'integer',
        ],
        'count' => [
          'type' => 'integer',
        ],
        'values' => [
          'type' => 'array',
        ],
      ],
    ];

  }

  /**
   * Item arguments.
   *
   * @since 5.25
   *
   * @return array $arguments
   */
  public function get_item_args() {

    return [
      'key' => [
        'type' => 'string',
        'required' => FALSE,
        'validate_callback' => function($value, $request, $key) {
          return $this->is_valid_site_key();
        },
      ],
      'api_key' => [
        'type' => 'string',
        'required' => FALSE,
        'validate_callback' => function($value, $request, $key) {
          return $this->is_valid_api_key($request);
        },
      ],
      'entity' => [
        'type' => 'string',
        'required' => TRUE,
        'validate_callback' => function($value, $request, $key) {
          return is_string($value);
        },
      ],
      'action' => [
        'type' => 'string',
        'required' => TRUE,
        'validate_callback' => function($value, $request, $key) {
          return is_string($value);
        },
      ],
      'json' => [
        'type' => ['integer', 'string', 'array'],
        'required' => FALSE,
        'validate_callback' => function($value, $request, $key) {
          return is_numeric($value) || is_array($value) || $this->is_valid_json($value);
        },
      ],
    ];

  }

  /**
   * Checks if string is a valid json.
   *
   * @since 5.25
   *
   * @param string $param
   * @return bool
   */
  public function is_valid_json($param) {

    $param = json_decode($param, TRUE);

    if (!is_array($param)) {
      return FALSE;
    }

    return (json_last_error() == JSON_ERROR_NONE);

  }

  /**
   * Validates the site key.
   *
   * @since 5.25
   *
   * @return bool $is_valid_site_key
   */
  public function is_valid_site_key() {

    return \CRM_Utils_System::authenticateKey(FALSE);

  }

  /**
   * Validates the api key.
   *
   * @since 5.25
   *
   * @param WP_REST_Resquest $request
   * @return bool $is_valid_api_key
   */
  public function is_valid_api_key($request) {

    $api_key = $request->get_param('api_key');

    if (!$api_key) {
      return FALSE;
    }

    $contact_id = \CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $api_key, 'id', 'api_key');

    if (!$contact_id) {
      return FALSE;
    }

    return TRUE;

  }

}
