<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Communicate with backpacks.
 *
 * @copyright  2012 onwards Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 * @author     Yuliya Bozhko <yuliya.bozhko@totaralms.com>
 */

namespace core_badges;

defined('MOODLE_INTERNAL') || die();

require_once($CFG->libdir . '/filelib.php');

use cache;
use coding_exception;
use core_badges\external\assertion_exporter;
use core_badges\external\collection_exporter;
use core_badges\external\issuer_exporter;
use core_badges\external\badgeclass_exporter;
use curl;
use stdClass;
use context_system;

define('BADGE_ACCESS_TOKEN', 'access');
define('BADGE_USER_ID_TOKEN', 'user_id');
define('BADGE_BACKPACK_ID_TOKEN', 'backpack_id');
define('BADGE_REFRESH_TOKEN', 'refresh');
define('BADGE_EXPIRES_TOKEN', 'expires');

/**
 * Class for communicating with backpacks.
 *
 * @package   core_badges
 * @copyright  2012 onwards Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class backpack_api {

    /** @var string The email address of the issuer or the backpack owner. */
    private $email;

    /** @var string The base url used for api requests to this backpack. */
    private $backpackapiurl;

    /** @var integer The backpack api version to use. */
    private $backpackapiversion;

    /** @var string The password to authenticate requests. */
    private $password;

    /** @var boolean User or site api requests. */
    private $isuserbackpack;

    /** @var integer The id of the backpack we are talking to. */
    private $backpackid;

    /** @var \backpack_api_mapping[] List of apis for the user or site using api version 1 or 2. */
    private $mappings = [];

    /**
     * Create a wrapper to communicate with the backpack.
     *
     * The resulting class can only do either site backpack communication or
     * user backpack communication.
     *
     * @param stdClass $sitebackpack The site backpack record
     * @param mixed $userbackpack Optional - if passed it represents the users backpack.
     */
    public function __construct($sitebackpack, $userbackpack = false) {
        global $CFG;
        $admin = get_admin();

        $this->backpackapiurl = $sitebackpack->backpackapiurl;
        $this->backpackapiversion = $sitebackpack->apiversion;
        $this->password = $sitebackpack->password;
        $this->email = $sitebackpack->backpackemail;
        $this->isuserbackpack = false;
        $this->backpackid = $sitebackpack->id;
        if (!empty($userbackpack)) {
            $this->isuserbackpack = true;
            $this->password = $userbackpack->password;
            $this->email = $userbackpack->email;
        }

        $this->define_mappings();
        // Clear the last authentication error.
        backpack_api_mapping::set_authentication_error('');
    }

    /**
     * Define the mappings supported by this usage and api version.
     */
    private function define_mappings() {
        if ($this->backpackapiversion == OPEN_BADGES_V2) {
            if ($this->isuserbackpack) {
                $mapping = [];
                $mapping[] = [
                    'collections',                              // Action.
                    '[URL]/backpack/collections',               // URL
                    [],                                         // Post params.
                    '',                                         // Request exporter.
                    'core_badges\external\collection_exporter', // Response exporter.
                    true,                                       // Multiple.
                    'get',                                      // Method.
                    true,                                       // JSON Encoded.
                    true                                        // Auth required.
                ];
                $mapping[] = [
                    'user',                                     // Action.
                    '[SCHEME]://[HOST]/o/token',                // URL
                    ['username' => '[EMAIL]', 'password' => '[PASSWORD]'], // Post params.
                    '',                                         // Request exporter.
                    'oauth_token_response',                     // Response exporter.
                    false,                                      // Multiple.
                    'post',                                     // Method.
                    false,                                      // JSON Encoded.
                    false,                                      // Auth required.
                ];
                $mapping[] = [
                    'assertion',                                // Action.
                    // Badgr.io does not return the public information about a badge
                    // if the issuer is associated with another user. We need to pass
                    // the expand parameters which are not in any specification to get
                    // additional information about the assertion in a single request.
                    '[URL]/backpack/assertions/[PARAM2]?expand=badgeclass&expand=issuer',
                    [],                                         // Post params.
                    '',                                         // Request exporter.
                    'core_badges\external\assertion_exporter',  // Response exporter.
                    false,                                      // Multiple.
                    'get',                                      // Method.
                    true,                                       // JSON Encoded.
                    true                                        // Auth required.
                ];
                $mapping[] = [
                    'importbadge',                                // Action.
                    // Badgr.io does not return the public information about a badge
                    // if the issuer is associated with another user. We need to pass
                    // the expand parameters which are not in any specification to get
                    // additional information about the assertion in a single request.
                    '[URL]/backpack/import',
                    ['url' => '[PARAM]'],  // Post params.
                    '',                                             // Request exporter.
                    'core_badges\external\assertion_exporter',      // Response exporter.
                    false,                                          // Multiple.
                    'post',                                         // Method.
                    true,                                           // JSON Encoded.
                    true                                            // Auth required.
                ];
                $mapping[] = [
                    'badges',                                   // Action.
                    '[URL]/backpack/collections/[PARAM1]',      // URL
                    [],                                         // Post params.
                    '',                                         // Request exporter.
                    'core_badges\external\collection_exporter', // Response exporter.
                    true,                                       // Multiple.
                    'get',                                      // Method.
                    true,                                       // JSON Encoded.
                    true                                        // Auth required.
                ];
                foreach ($mapping as $map) {
                    $map[] = true; // User api function.
                    $map[] = OPEN_BADGES_V2; // V2 function.
                    $this->mappings[] = new backpack_api_mapping(...$map);
                }
            } else {
                $mapping = [];
                $mapping[] = [
                    'user',                                     // Action.
                    '[SCHEME]://[HOST]/o/token',                // URL
                    ['username' => '[EMAIL]', 'password' => '[PASSWORD]'], // Post params.
                    '',                                         // Request exporter.
                    'oauth_token_response',                     // Response exporter.
                    false,                                      // Multiple.
                    'post',                                     // Method.
                    false,                                      // JSON Encoded.
                    false                                       // Auth required.
                ];
                $mapping[] = [
                    'issuers',                                  // Action.
                    '[URL]/issuers',                            // URL
                    '[PARAM]',                                  // Post params.
                    'core_badges\external\issuer_exporter',     // Request exporter.
                    'core_badges\external\issuer_exporter',     // Response exporter.
                    false,                                      // Multiple.
                    'post',                                     // Method.
                    true,                                       // JSON Encoded.
                    true                                        // Auth required.
                ];
                $mapping[] = [
                    'badgeclasses',                             // Action.
                    '[URL]/issuers/[PARAM2]/badgeclasses',      // URL
                    '[PARAM]',                                  // Post params.
                    'core_badges\external\badgeclass_exporter', // Request exporter.
                    'core_badges\external\badgeclass_exporter', // Response exporter.
                    false,                                      // Multiple.
                    'post',                                     // Method.
                    true,                                       // JSON Encoded.
                    true                                        // Auth required.
                ];
                $mapping[] = [
                    'assertions',                               // Action.
                    '[URL]/badgeclasses/[PARAM2]/assertions',   // URL
                    '[PARAM]',                                  // Post params.
                    'core_badges\external\assertion_exporter', // Request exporter.
                    'core_badges\external\assertion_exporter', // Response exporter.
                    false,                                      // Multiple.
                    'post',                                     // Method.
                    true,                                       // JSON Encoded.
                    true                                        // Auth required.
                ];
                $mapping[] = [
                    'updateassertion',                                // Action.
                    '[URL]/assertions/[PARAM2]?expand=badgeclass&expand=issuer',
                    '[PARAM]',                                  // Post params.
                    'core_badges\external\assertion_exporter', // Request exporter.
                    'core_badges\external\assertion_exporter', // Response exporter.
                    false,                                      // Multiple.
                    'put',                                     // Method.
                    true,                                       // JSON Encoded.
                    true                                        // Auth required.
                ];
                foreach ($mapping as $map) {
                    $map[] = false; // Site api function.
                    $map[] = OPEN_BADGES_V2; // V2 function.
                    $this->mappings[] = new backpack_api_mapping(...$map);
                }
            }
        } else {
            if ($this->isuserbackpack) {
                $mapping = [];
                $mapping[] = [
                    'user',                                     // Action.
                    '[URL]/displayer/convert/email',            // URL
                    ['email' => '[EMAIL]'],                     // Post params.
                    '',                                         // Request exporter.
                    'convert_email_response',                   // Response exporter.
                    false,                                      // Multiple.
                    'post',                                     // Method.
                    false,                                      // JSON Encoded.
                    false                                       // Auth required.
                ];
                $mapping[] = [
                    'groups',                                   // Action.
                    '[URL]/displayer/[PARAM1]/groups.json',     // URL
                    [],                                         // Post params.
                    '',                                         // Request exporter.
                    '',                                         // Response exporter.
                    false,                                      // Multiple.
                    'get',                                      // Method.
                    true,                                       // JSON Encoded.
                    true                                        // Auth required.
                ];
                $mapping[] = [
                    'badges',                                   // Action.
                    '[URL]/displayer/[PARAM2]/group/[PARAM1].json',     // URL
                    [],                                         // Post params.
                    '',                                         // Request exporter.
                    '',                                         // Response exporter.
                    false,                                      // Multiple.
                    'get',                                      // Method.
                    true,                                       // JSON Encoded.
                    true                                        // Auth required.
                ];
                foreach ($mapping as $map) {
                    $map[] = true; // User api function.
                    $map[] = OPEN_BADGES_V1; // V1 function.
                    $this->mappings[] = new backpack_api_mapping(...$map);
                }
            } else {
                $mapping = [];
                $mapping[] = [
                    'user',                                     // Action.
                    '[URL]/displayer/convert/email',            // URL
                    ['email' => '[EMAIL]'],                     // Post params.
                    '',                                         // Request exporter.
                    'convert_email_response',                   // Response exporter.
                    false,                                      // Multiple.
                    'post',                                     // Method.
                    false,                                      // JSON Encoded.
                    false                                       // Auth required.
                ];
                foreach ($mapping as $map) {
                    $map[] = false; // Site api function.
                    $map[] = OPEN_BADGES_V1; // V1 function.
                    $this->mappings[] = new backpack_api_mapping(...$map);
                }
            }
        }
    }

    /**
     * Make an api request
     *
     * @param string $action The api function.
     * @param string $collection An api parameter
     * @param string $entityid An api parameter
     * @param string $postdata The body of the api request.
     * @return mixed
     */
    private function curl_request($action, $collection = null, $entityid = null, $postdata = null) {
        global $CFG, $SESSION;

        $curl = new curl();
        $authrequired = false;
        if ($this->backpackapiversion == OPEN_BADGES_V1) {
            $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
            if (isset($SESSION->$useridkey)) {
                if ($collection == null) {
                    $collection = $SESSION->$useridkey;
                } else {
                    $entityid = $SESSION->$useridkey;
                }
            }
        }
        foreach ($this->mappings as $mapping) {
            if ($mapping->is_match($action)) {
                return $mapping->request(
                    $this->backpackapiurl,
                    $collection,
                    $entityid,
                    $this->email,
                    $this->password,
                    $postdata,
                    $this->backpackid
                );
            }
        }

        throw new coding_exception('Unknown request');
    }

    /**
     * Get the id to use for requests with this api.
     *
     * @return integer
     */
    private function get_auth_user_id() {
        global $USER;

        if ($this->isuserbackpack) {
            return $USER->id;
        } else {
            // The access tokens for the system backpack are shared.
            return -1;
        }
    }

    /**
     * Get the name of the key to store this access token type.
     *
     * @param string $type
     * @return string
     */
    private function get_token_key($type) {
        // This should be removed when everything has a mapping.
        $prefix = 'badges_';
        if ($this->isuserbackpack) {
            $prefix .= 'user_backpack_';
        } else {
            $prefix .= 'site_backpack_';
        }
        $prefix .= $type . '_token';
        return $prefix;
    }

    /**
     * Normalise the return from a missing user request.
     *
     * @param string $status
     * @return mixed
     */
    private function check_status($status) {
        // V1 ONLY.
        switch($status) {
            case "missing":
                $response = array(
                    'status'  => $status,
                    'message' => get_string('error:nosuchuser', 'badges')
                );
                return $response;
        }
        return false;
    }

    /**
     * Make an api request to get an assertion
     *
     * @param string $entityid The id of the assertion.
     * @return mixed
     */
    public function get_assertion($entityid) {
        // V2 Only.
        if ($this->backpackapiversion == OPEN_BADGES_V1) {
            throw new coding_exception('Not supported in this backpack API');
        }

        return $this->curl_request('assertion', null, $entityid);
    }

    /**
     * Create a badgeclass assertion.
     *
     * @param string $entityid The id of the badge class.
     * @param string $data The structure of the badge class assertion.
     * @return mixed
     */
    public function put_badgeclass_assertion($entityid, $data) {
        // V2 Only.
        if ($this->backpackapiversion == OPEN_BADGES_V1) {
            throw new coding_exception('Not supported in this backpack API');
        }

        return $this->curl_request('assertions', null, $entityid, $data);
    }

    /**
     * Update a badgeclass assertion.
     *
     * @param string $entityid The id of the badge class.
     * @param array $data The structure of the badge class assertion.
     * @return mixed
     */
    public function update_assertion(string $entityid, array $data) {
        // V2 Only.
        if ($this->backpackapiversion == OPEN_BADGES_V1) {
            throw new coding_exception('Not supported in this backpack API');
        }

        return $this->curl_request('updateassertion', null, $entityid, $data);
    }

    /**
     * Import a badge assertion into a backpack. This is used to handle cross domain backpacks.
     *
     * @param string $data The structure of the badge class assertion.
     * @return mixed
     * @throws coding_exception
     */
    public function import_badge_assertion(string $data) {
        // V2 Only.
        if ($this->backpackapiversion == OPEN_BADGES_V1) {
            throw new coding_exception('Not supported in this backpack API');
        }

        return $this->curl_request('importbadge', null, null, $data);
    }

    /**
     * Select collections from a backpack.
     *
     * @param string $backpackid The id of the backpack
     * @param stdClass[] $collections List of collections with collectionid or entityid.
     * @return boolean
     */
    public function set_backpack_collections($backpackid, $collections) {
        global $DB, $USER;

        // Delete any previously selected collections.
        $sqlparams = array('backpack' => $backpackid);
        $select = 'backpackid = :backpack ';
        $DB->delete_records_select('badge_external', $select, $sqlparams);
        $badgescache = cache::make('core', 'externalbadges');

        // Insert selected collections if they are not in database yet.
        foreach ($collections as $collection) {
            $obj = new stdClass();
            $obj->backpackid = $backpackid;
            if ($this->backpackapiversion == OPEN_BADGES_V1) {
                $obj->collectionid = (int) $collection;
            } else {
                $obj->entityid = $collection;
                $obj->collectionid = -1;
            }
            if (!$DB->record_exists('badge_external', (array) $obj)) {
                $DB->insert_record('badge_external', $obj);
            }
        }
        $badgescache->delete($USER->id);
        return true;
    }

    /**
     * Create a badgeclass
     *
     * @param string $entityid The id of the entity.
     * @param string $data The structure of the badge class.
     * @return mixed
     */
    public function put_badgeclass($entityid, $data) {
        // V2 Only.
        if ($this->backpackapiversion == OPEN_BADGES_V1) {
            throw new coding_exception('Not supported in this backpack API');
        }

        return $this->curl_request('badgeclasses', null, $entityid, $data);
    }

    /**
     * Create an issuer
     *
     * @param string $data The structure of the issuer.
     * @return mixed
     */
    public function put_issuer($data) {
        // V2 Only.
        if ($this->backpackapiversion == OPEN_BADGES_V1) {
            throw new coding_exception('Not supported in this backpack API');
        }

        return $this->curl_request('issuers', null, null, $data);
    }

    /**
     * Delete any user access tokens in the session so we will attempt to get new ones.
     *
     * @return void
     */
    public function clear_system_user_session() {
        global $SESSION;

        $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
        unset($SESSION->$useridkey);

        $expireskey = $this->get_token_key(BADGE_EXPIRES_TOKEN);
        unset($SESSION->$expireskey);
    }

    /**
     * Authenticate using the stored email and password and save the valid access tokens.
     *
     * @return mixed The id of the authenticated user as returned by the backpack. Can have
     *    different formats - numeric, empty, object with 'error' property, etc.
     */
    public function authenticate() {
        global $SESSION;

        $backpackidkey = $this->get_token_key(BADGE_BACKPACK_ID_TOKEN);
        $backpackid = isset($SESSION->$backpackidkey) ? $SESSION->$backpackidkey : 0;
        // If the backpack is changed we need to expire sessions.
        if ($backpackid == $this->backpackid) {
            if ($this->backpackapiversion == OPEN_BADGES_V2) {
                $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
                $authuserid = isset($SESSION->$useridkey) ? $SESSION->$useridkey : 0;
                if ($authuserid == $this->get_auth_user_id()) {
                    $expireskey = $this->get_token_key(BADGE_EXPIRES_TOKEN);
                    if (isset($SESSION->$expireskey)) {
                        $expires = $SESSION->$expireskey;
                        if ($expires > time()) {
                            // We have a current access token for this user
                            // that has not expired.
                            return -1;
                        }
                    }
                }
            } else {
                $useridkey = $this->get_token_key(BADGE_USER_ID_TOKEN);
                $authuserid = isset($SESSION->$useridkey) ? $SESSION->$useridkey : 0;
                if (!empty($authuserid)) {
                    return $authuserid;
                }
            }
        }
        return $this->curl_request('user', $this->email);
    }

    /**
     * Get all collections in this backpack.
     *
     * @return stdClass[] The collections.
     */
    public function get_collections() {
        global $PAGE;

        if ($this->authenticate()) {
            if ($this->backpackapiversion == OPEN_BADGES_V1) {
                $result = $this->curl_request('groups');
                if (isset($result->groups)) {
                    $result = $result->groups;
                }
            } else {
                $result = $this->curl_request('collections');
            }
            if ($result) {
                return $result;
            }
        }
        return [];
    }

    /**
     * Get one collection by id.
     *
     * @param integer $collectionid
     * @return stdClass The collection.
     */
    public function get_collection_record($collectionid) {
        global $DB;

        if ($this->backpackapiversion == OPEN_BADGES_V1) {
            return $DB->get_fieldset_select('badge_external', 'collectionid', 'backpackid = :bid', array('bid' => $collectionid));
        } else {
            return $DB->get_fieldset_select('badge_external', 'entityid', 'backpackid = :bid', array('bid' => $collectionid));
        }
    }

    /**
     * Disconnect the backpack from this user.
     *
     * @param integer $userid The user in Moodle
     * @param integer $backpackid The backpack to disconnect
     * @return boolean
     */
    public function disconnect_backpack($userid, $backpackid) {
        global $DB, $USER;

        if (\core\session\manager::is_loggedinas() || $userid != $USER->id) {
            // Can't change someone elses backpack settings.
            return false;
        }

        $badgescache = cache::make('core', 'externalbadges');

        $DB->delete_records('badge_external', array('backpackid' => $backpackid));
        $DB->delete_records('badge_backpack', array('userid' => $userid));
        $badgescache->delete($userid);
        return true;
    }

    /**
     * Handle the response from getting a collection to map to an id.
     *
     * @param stdClass $data The response data.
     * @return string The collection id.
     */
    public function get_collection_id_from_response($data) {
        if ($this->backpackapiversion == OPEN_BADGES_V1) {
            return $data->groupId;
        } else {
            return $data->entityId;
        }
    }

    /**
     * Get the last error message returned during an authentication request.
     *
     * @return string
     */
    public function get_authentication_error() {
        return backpack_api_mapping::get_authentication_error();
    }

    /**
     * Get the list of badges in a collection.
     *
     * @param stdClass $collection The collection to deal with.
     * @param boolean $expanded Fetch all the sub entities.
     * @return stdClass[]
     */
    public function get_badges($collection, $expanded = false) {
        global $PAGE;

        if ($this->authenticate()) {
            if ($this->backpackapiversion == OPEN_BADGES_V1) {
                if (empty($collection->collectionid)) {
                    return [];
                }
                $result = $this->curl_request('badges', $collection->collectionid);
                return $result->badges;
            } else {
                if (empty($collection->entityid)) {
                    return [];
                }
                // Now we can make requests.
                $badges = $this->curl_request('badges', $collection->entityid);
                if (count($badges) == 0) {
                    return [];
                }
                $badges = $badges[0];
                if ($expanded) {
                    $publicassertions = [];
                    $context = context_system::instance();
                    $output = $PAGE->get_renderer('core', 'badges');
                    foreach ($badges->assertions as $assertion) {
                        $remoteassertion = $this->get_assertion($assertion);
                        // Remote badge was fetched nested in the assertion.
                        $remotebadge = $remoteassertion->badgeclass;
                        if (!$remotebadge) {
                            continue;
                        }
                        $apidata = badgeclass_exporter::map_external_data($remotebadge, $this->backpackapiversion);
                        $exporterinstance = new badgeclass_exporter($apidata, ['context' => $context]);
                        $remotebadge = $exporterinstance->export($output);

                        $remoteissuer = $remotebadge->issuer;
                        $apidata = issuer_exporter::map_external_data($remoteissuer, $this->backpackapiversion);
                        $exporterinstance = new issuer_exporter($apidata, ['context' => $context]);
                        $remoteissuer = $exporterinstance->export($output);

                        $badgeclone = clone $remotebadge;
                        $badgeclone->issuer = $remoteissuer;
                        $remoteassertion->badge = $badgeclone;
                        $remotebadge->assertion = $remoteassertion;
                        $publicassertions[] = $remotebadge;
                    }
                    $badges = $publicassertions;
                }
                return $badges;
            }
        }
    }
}
