<?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/>.

namespace core_badges;

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

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

use cache;
use coding_exception;
use core_badges\external\issuer_exporter;
use core_badges\external\badgeclass_exporter;
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
 * @author     Yuliya Bozhko <yuliya.bozhko@totaralms.com>
 * @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 int Canvas Credentials backpack provider */
    public const PROVIDER_CANVAS_CREDENTIALS = 0;

    /** @var int Other backpack provider */
    public const PROVIDER_OTHER = 1;

    /** @var int Empty provider */
    public const PROVIDER_EMPTY = -1;

    /** @var int Empty region */
    public const REGION_EMPTY = -1;

    /** @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 \core_badges\backpack_api_mapping[] List of apis for the user or site using api version 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->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);
            }
        }
    }

    /**
     * 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) {
        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;
    }

    /**
     * Make an api request to get an assertion
     *
     * @param string $entityid The id of the assertion.
     * @return mixed
     */
    public function get_assertion($entityid) {
        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) {
        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) {
        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) {
        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;
            $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) {
        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) {
        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() {
        if ($this->authenticate()) {
            $result = $this->curl_request('collections');
            if ($result) {
                return $result;
            }
        }
        return [];
    }

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

        return $DB->get_fieldset_select('badge_external', 'entityid', 'backpackid = :bid', ['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);
        $this->clear_system_user_session();

        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) {
        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();
    }

    /**
     * List all errors occurred during the requests to the backpack.
     *
     * @return array The list of errors.
     */
    public function get_errors(): array {
        $errors = [];
        foreach ($this->mappings as $mapping) {
            $errors = array_merge($errors, $mapping->get_errors());
        }

        return $errors;
    }

    /**
     * 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 (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;
        }

        return [];
    }

    /**
     *  Get list of backpack providers for OBv2.0.
     *
     * @return string[] Array with the OBv2.0 backpack providers.
     */
    public static function get_providers(): array {
        $allproviders = [
            self::PROVIDER_CANVAS_CREDENTIALS => 'canvascredentialsprovider',
            self::PROVIDER_OTHER => 'otherprovider',
        ];

        foreach ($allproviders as $key => $value) {
            if (get_string_manager()->string_exists($value, 'badges')) {
                $providers[$key] = get_string($value, 'badges');
            } else {
                // If the string does not exist, use the key as a fallback.
                $providers[$key] = $value;
            }
        }
        return $providers;
    }

    /**
     * Get list of regions for backpack providers.
     *
     * @return array Regions with the following information: name, url and apiurl.
     */
    public static function get_regions() {
        global $CFG;

        $regions = [];
        if (empty(trim($CFG->badges_canvasregions))) {
            return $regions;
        }

        $entries = explode("\n", $CFG->badges_canvasregions);
        foreach ($entries as $entry) {
            if (empty(trim($entry)) || substr_count($entry, '|') != 2) {
                continue;
            }
            $entry = trim($entry);
            $parts = explode('|', $entry);
            $regions[] = [
                'name' => $parts[0],
                'url' => rtrim($parts[1], '/'),
                'apiurl' => rtrim($parts[2], '/'),
            ];
        }

        return $regions;
    }

    /**
     * Whether the Canvas Credentials fields should be displayed or not in the backpack form.
     *
     * @return bool True if the fields should be displayed; false otherwise.
     */
    public static function display_canvas_credentials_fields(): bool {
        return !empty(self::get_providers()) && !empty(self::get_regions());
    }

    /**
     * Get backpack URL for a given regionid.
     *
     * @param int $regionid The region identifier.
     * @return string|null The backpack URL.
     */
    public static function get_region_url(int $regionid): ?string {
        $regions = self::get_regions();
        if (!array_key_exists($regionid, $regions)) {
            return null;
        }
        return $regions[$regionid]['url'];
    }

    /**
     * Get backpack API URL for a given regionid.
     *
     * @param int $regionid The region identifier.
     * @return string|null The backpack API URL.
     */
    public static function get_region_api_url(int $regionid): ?string {
        $regions = self::get_regions();
        if (!array_key_exists($regionid, $regions)) {
            return null;
        }
        return $regions[$regionid]['apiurl'];
    }

    /**
     * Get region identifier from a given backpack URL.
     * When the URL is not found, the last region index is returned.
     *
     * @param string $url The backpack URL.
     * @return int The region identifier associated to the given backpack URL or the last region index if not found.
     */
    public static function get_regionid_from_url(string $url): int {
        $regions = self::get_regions();
        if (empty($regions)) {
            return self::REGION_EMPTY;
        }

        // Normalize the URL by removing the trailing slash.
        $normalizedurl = rtrim($url, '/');
        $regionurl = array_search($normalizedurl, array_column($regions, 'url'));
        return $regionurl !== false ? (int)$regionurl : count($regions) - 1;
    }

    /**
     * Check whether the given URL is a Canvas Credentials one.
     *
     * @param string $url The backpack URL.
     * @return bool True is the given URL is a Canvas Credentials region; false otherwise.
     */
    public static function is_canvas_credentials_region(string $url): bool {
        $regions = self::get_regions();
        return in_array($url, array_column($regions, 'url'));
    }
}
