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

/**
 * Class for loading/storing oauth2 linked logins from the DB.
 *
 * @package    auth_oauth2
 * @copyright  2017 Damyon Wiese
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
namespace auth_oauth2;

use context_user;
use core\clock;
use core\di;
use stdClass;
use moodle_exception;
use moodle_url;

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

/**
 * Static list of api methods for auth oauth2 configuration.
 *
 * @package    auth_oauth2
 * @copyright  2017 Damyon Wiese
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class api {
    /**
     * @var string Interval string of the expiration duration
     * @see https://www.php.net/manual/en/dateinterval.construct.php
     */
    public const CONFIRMTOKEN_EXPIRES = 'PT30M';

    /**
     * Remove all linked logins that are using issuers that have been deleted.
     *
     * @param int $issuerid The issuer id of the issuer to check, or false to check all (defaults to all)
     * @return boolean
     */
    public static function clean_orphaned_linked_logins($issuerid = false) {
        return linked_login::delete_orphaned($issuerid);
    }

    /**
     * List linked logins
     *
     * Requires auth/oauth2:managelinkedlogins capability at the user context.
     *
     * @param int $userid (defaults to $USER->id)
     * @return linked_login[]
     */
    public static function get_linked_logins($userid = false) {
        global $USER;

        if ($userid === false) {
            $userid = $USER->id;
        }

        if (\core\session\manager::is_loggedinas()) {
            throw new moodle_exception('notwhileloggedinas', 'auth_oauth2');
        }

        $context = context_user::instance($userid);
        require_capability('auth/oauth2:managelinkedlogins', $context);

        return linked_login::get_records(['userid' => $userid, 'confirmtoken' => '']);
    }

    /**
     * See if there is a match for this username and issuer in the linked_login table.
     *
     * @param string $username as returned from an oauth client.
     * @param \core\oauth2\issuer $issuer
     * @return linked_login|false record if found and user exists, false otherwise.
     */
    public static function match_username_to_user($username, $issuer) {
        global $DB;

        $where = "issuerid = :issuerid
              AND username = :username
              AND (confirmtokenexpires = 0 OR confirmtokenexpires > :now)";

        $record = $DB->get_record_select(linked_login::TABLE, $where, [
            'issuerid' => $issuer->get('id'),
            'username' => $username,
            'now' => di::get(clock::class)->now()->getTimestamp(),
        ]);

        if ($record) {
            $result = new linked_login(0, $record);
            $user = \core_user::get_user($result->get('userid'));
            if (!empty($user) && !$user->deleted) {
                return $result;
            }
        }
        return false;
    }

    /**
     * Link a login to this account.
     *
     * Requires auth/oauth2:managelinkedlogins capability at the user context.
     *
     * @param array $userinfo as returned from an oauth client.
     * @param \core\oauth2\issuer $issuer
     * @param int $userid (defaults to $USER->id)
     * @param bool $skippermissions During signup we need to set this before the user is setup for capability checks.
     * @return linked_login
     */
    public static function link_login($userinfo, $issuer, $userid = false, $skippermissions = false) {
        global $USER;

        if ($userid === false) {
            $userid = $USER->id;
        }

        if (linked_login::has_existing_issuer_match($issuer, $userinfo['username'])) {
            throw new moodle_exception('alreadylinked', 'auth_oauth2');
        }

        if (\core\session\manager::is_loggedinas()) {
            throw new moodle_exception('notwhileloggedinas', 'auth_oauth2');
        }

        $context = context_user::instance($userid);
        if (!$skippermissions) {
            require_capability('auth/oauth2:managelinkedlogins', $context);
        }

        $record = new stdClass();
        $record->issuerid = $issuer->get('id');
        $record->username = $userinfo['username'];
        $record->userid = $userid;
        $existing = linked_login::get_record((array)$record);
        if ($existing) {
            $existing->set('confirmtoken', '');
            $existing->update();
            return $existing;
        }
        $record->email = $userinfo['email'];
        $record->confirmtoken = '';
        $record->confirmtokenexpires = 0;
        $linkedlogin = new linked_login(0, $record);
        return $linkedlogin->create();
    }

    /**
     * Send an email with a link to confirm linking this account.
     *
     * @param array $userinfo as returned from an oauth client.
     * @param \core\oauth2\issuer $issuer
     * @param int $userid (defaults to $USER->id)
     * @return bool
     */
    public static function send_confirm_link_login_email($userinfo, $issuer, $userid) {
        $record = new stdClass();
        $record->issuerid = $issuer->get('id');
        $record->username = $userinfo['username'];
        $record->userid = $userid;
        if (linked_login::has_existing_issuer_match($issuer, $userinfo['username'])) {
            throw new moodle_exception('alreadylinked', 'auth_oauth2');
        }
        $record->email = $userinfo['email'];
        $record->confirmtoken = random_string(32);
        $expires = new \DateTime('NOW');
        $expires->add(new \DateInterval(self::CONFIRMTOKEN_EXPIRES));
        $record->confirmtokenexpires = $expires->getTimestamp();

        linked_login::delete_expired_pending($issuer, $userinfo['username'], $userid);

        $linkedlogin = new linked_login(0, $record);
        $linkedlogin->create();

        // Construct the email.
        $site = get_site();
        $supportuser = \core_user::get_support_user();
        $user = get_complete_user_data('id', $userid);

        $data = new stdClass();
        $placeholders = \core_user::get_name_placeholders($user);
        foreach ($placeholders as $field => $value) {
            $data->{$field} = $value;
        }
        $data->sitename  = format_string($site->fullname);
        $data->admin     = generate_email_signoff();
        $data->issuername = format_string($issuer->get('name'));
        $data->linkedemail = format_string($linkedlogin->get('email'));

        $subject = get_string('confirmlinkedloginemailsubject', 'auth_oauth2', format_string($site->fullname));

        $params = [
            'token' => $linkedlogin->get('confirmtoken'),
            'userid' => $userid,
            'username' => $userinfo['username'],
            'issuerid' => $issuer->get('id'),
        ];
        $confirmationurl = new moodle_url('/auth/oauth2/confirm-linkedlogin.php', $params);

        $data->link = $confirmationurl->out(false);
        $message = get_string('confirmlinkedloginemail', 'auth_oauth2', $data);
        $messagehtml = text_to_html(get_string('confirmlinkedloginemail', 'auth_oauth2', $data), false, false);

        $user->mailformat = 1;  // Always send HTML version as well.

        // Directly email rather than using the messaging system to ensure its not routed to a popup or jabber.
        return email_to_user($user, $supportuser, $subject, $message, $messagehtml);
    }

    /**
     * Look for a waiting confirmation token, and if we find a match - confirm it.
     *
     * @param int $userid
     * @param string $username
     * @param int $issuerid
     * @param string $token
     * @return boolean True if we linked.
     */
    public static function confirm_link_login($userid, $username, $issuerid, $token) {
        if (empty($token) || empty($userid) || empty($issuerid) || empty($username)) {
            return false;
        }
        $params = [
            'userid' => $userid,
            'username' => $username,
            'issuerid' => $issuerid,
            'confirmtoken' => $token,
        ];

        $login = linked_login::get_record($params);
        if (empty($login)) {
            return false;
        }
        $expires = $login->get('confirmtokenexpires');
        if (time() > $expires) {
            $login->delete();
            return false;
        }
        $login->set('confirmtokenexpires', 0);
        $login->set('confirmtoken', '');
        $login->update();
        return true;
    }

    /**
     * Create an account with a linked login that is already confirmed.
     *
     * @param array $userinfo as returned from an oauth client.
     * @param \core\oauth2\issuer $issuer
     * @return stdClass
     */
    public static function create_new_confirmed_account($userinfo, $issuer) {
        global $CFG, $DB;
        require_once($CFG->dirroot.'/user/profile/lib.php');
        require_once($CFG->dirroot.'/user/lib.php');

        $user = new stdClass();
        $user->auth = 'oauth2';
        $user->mnethostid = $CFG->mnet_localhost_id;
        $user->secret = random_string(15);
        $user->password = '';
        $user->confirmed = 1;  // Set the user to confirmed.

        $user = self::save_user($userinfo, $user);

        // The linked account is pre-confirmed.
        $record = new stdClass();
        $record->issuerid = $issuer->get('id');
        $record->username = $userinfo['username'];
        $record->userid = $user->id;
        $record->email = $userinfo['email'];
        $record->confirmtoken = '';
        $record->confirmtokenexpires = 0;

        $linkedlogin = new linked_login(0, $record);
        $linkedlogin->create();

        return $user;
    }

    /**
     * Send an email with a link to confirm creating this account.
     *
     * @param array $userinfo as returned from an oauth client.
     * @param \core\oauth2\issuer $issuer
     * @param int $userid (defaults to $USER->id)
     * @return stdClass
     */
    public static function send_confirm_account_email($userinfo, $issuer) {
        global $CFG, $DB;
        require_once($CFG->dirroot.'/user/profile/lib.php');
        require_once($CFG->dirroot.'/user/lib.php');

        if (linked_login::has_existing_issuer_match($issuer, $userinfo['username'])) {
            throw new moodle_exception('alreadylinked', 'auth_oauth2');
        }

        $user = new stdClass();
        $user->auth = 'oauth2';
        $user->mnethostid = $CFG->mnet_localhost_id;
        $user->secret = random_string(15);
        $user->password = '';
        $user->confirmed = 0;  // The user is not yet confirmed.

        $user = self::save_user($userinfo, $user);

        // The linked account is pre-confirmed.
        $record = new stdClass();
        $record->issuerid = $issuer->get('id');
        $record->username = $userinfo['username'];
        $record->userid = $user->id;
        $record->email = $userinfo['email'];
        $record->confirmtoken = '';
        $record->confirmtokenexpires = 0;

        $linkedlogin = new linked_login(0, $record);
        $linkedlogin->create();

        // Construct the email.
        $site = get_site();
        $supportuser = \core_user::get_support_user();
        $user = get_complete_user_data('id', $user->id);

        $data = new stdClass();
        $placeholders = \core_user::get_name_placeholders($user);
        foreach ($placeholders as $field => $value) {
            $data->{$field} = $value;
        }
        $data->sitename  = format_string($site->fullname);
        $data->admin     = generate_email_signoff();

        $subject = get_string('confirmaccountemailsubject', 'auth_oauth2', format_string($site->fullname));

        $params = [
            'token' => $user->secret,
            'username' => $userinfo['username']
        ];
        $confirmationurl = new moodle_url('/auth/oauth2/confirm-account.php', $params);

        $data->link = $confirmationurl->out(false);
        $message = get_string('confirmaccountemail', 'auth_oauth2', $data);
        $messagehtml = text_to_html(get_string('confirmaccountemail', 'auth_oauth2', $data), false, false);

        $user->mailformat = 1;  // Always send HTML version as well.

        // Directly email rather than using the messaging system to ensure its not routed to a popup or jabber.
        email_to_user($user, $supportuser, $subject, $message, $messagehtml);
        return $user;
    }

    /**
     * Delete a users own linked login
     *
     * Requires auth/oauth2:managelinkedlogins capability at the user context.
     *
     * @param int $linkedloginid
     */
    public static function delete_linked_login($linkedloginid) {
        global $USER;

        if (\core\session\manager::is_loggedinas()) {
            throw new moodle_exception('notwhileloggedinas', 'auth_oauth2');
        }

        $login = linked_login::get_record([
            'id' => $linkedloginid,
            'userid' => $USER->id,
            'confirmtoken' => '',
        ], MUST_EXIST);

        $context = context_user::instance($login->get('userid'));
        require_capability('auth/oauth2:managelinkedlogins', $context);

        $login->delete();
    }

    /**
     * Delete linked logins for a user.
     *
     * @param \core\event\user_deleted $event
     * @return boolean
     */
    public static function user_deleted(\core\event\user_deleted $event) {
        global $DB;

        $userid = $event->objectid;

        return $DB->delete_records(linked_login::TABLE, ['userid' => $userid]);
    }

    /**
     * Is the plugin enabled.
     *
     * @return bool
     */
    public static function is_enabled() {
        return is_enabled_auth('oauth2');
    }

    /**
     * Create a new user & update the profile fields
     *
     * @param array $userinfo
     * @param object $user
     * @return stdClass
     */
    private static function save_user(array $userinfo, object $user): object {
        // Map supplied issuer user info to Moodle user fields.
        $userfieldmapping = new \core\oauth2\user_field_mapping();
        $userfieldlist = $userfieldmapping->get_internalfields();
        $hasprofilefield = false;
        foreach ($userfieldlist as $field) {
            if (isset($userinfo[$field]) && $userinfo[$field]) {
                $user->$field = $userinfo[$field];

                // Check whether the profile fields exist or not.
                $hasprofilefield = $hasprofilefield || strpos($field, \core_user\fields::PROFILE_FIELD_PREFIX) === 0;
            }
        }

        // Create a new user.
        $user->id = user_create_user($user, false, true);

        // If profile fields exist then save custom profile fields data.
        if ($hasprofilefield) {
            profile_save_data($user);
        }

        return $user;
    }
}
