<?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 enrol_lti\local\ltiadvantage\repository;
use enrol_lti\local\ltiadvantage\entity\application_registration;
use enrol_lti\local\ltiadvantage\entity\user;

/**
 * Tests for user_repository objects.
 *
 * @package enrol_lti
 * @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com>
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 * @coversDefaultClass \enrol_lti\local\ltiadvantage\repository\user_repository
 */
class user_repository_test extends \advanced_testcase {
    /**
     * Helper to generate a new user instance.
     *
     * @param int $mockresourceid used to spoof a published resource, to which this user is associated.
     * @param array $userfields user information like city, timezone which would normally come from the tool configuration.
     * @return user a user instance
     */
    protected function generate_user(int $mockresourceid = 1, array $userfields = []): user {
        global $CFG;
        $registration = application_registration::create(
            'Test',
            'a2c94a2c94',
            new \moodle_url('http://lms.example.org'),
            'clientid_123',
            new \moodle_url('https://example.org/authrequesturl'),
            new \moodle_url('https://example.org/jwksurl'),
            new \moodle_url('https://example.org/accesstokenurl')
        );
        $registrationrepo = new application_registration_repository();
        $createdregistration = $registrationrepo->save($registration);

        $deployment = $createdregistration->add_tool_deployment('Deployment 1', 'DeployID123');
        $deploymentrepo = new deployment_repository();
        $saveddeployment = $deploymentrepo->save($deployment);

        $contextrepo = new context_repository();
        $context = $saveddeployment->add_context(
            'CTX123',
            ['http://purl.imsglobal.org/vocab/lis/v2/course#CourseSection']
        );
        $savedcontext = $contextrepo->save($context);

        $resourcelinkrepo = new resource_link_repository();
        $resourcelink = $saveddeployment->add_resource_link('resourcelinkid_123', $mockresourceid,
            $savedcontext->get_id());
        $savedresourcelink = $resourcelinkrepo->save($resourcelink);

        // Create a user using the DB defaults to simulate what would have occurred during an auth_lti user auth.
        $user = $this->getDataGenerator()->create_user([
            'city' => '',
            'country' => '',
            'institution' => '',
            'timezone' => '99',
            'maildisplay' => 2,
            'lang' => 'en'
        ]);

        $userdefaultvalues = [
            'lang' => $CFG->lang,
            'city' => '',
            'country' => '',
            'institution' => '',
            'timezone' => '99',
            'maildisplay' => 2
        ];
        if (empty($userfields)) {
            // If userfields is omitted, assume the tool default configuration values (as if 'User default values' are unchanged).
            $userfields = $userdefaultvalues;
        } else {
            // If they have been provided, merge and override the defaults.
            $userfields = array_merge($userdefaultvalues, $userfields);
        }
        $ltiuser = $savedresourcelink->add_user(
            $user->id,
            'source-id-123',
            ...array_values($userfields)
        );

        $ltiuser->set_lastgrade(67.33333333);

        return $ltiuser;
    }

    /**
     * Helper to assert that all the key elements of two users (i.e. excluding id) are equal.
     *
     * @param user $expected the user whose values are deemed correct.
     * @param user $check the user to check.
     * @param bool $checkresourcelink whether or not to confirm the resource link value matches too.
     */
    protected function assert_same_user_values(user $expected, user $check, bool $checkresourcelink = false): void {
        $this->assertEquals($expected->get_deploymentid(), $check->get_deploymentid());
        $this->assertEquals($expected->get_city(), $check->get_city());
        $this->assertEquals($expected->get_country(), $check->get_country());
        $this->assertEquals($expected->get_institution(), $check->get_institution());
        $this->assertEquals($expected->get_timezone(), $check->get_timezone());
        $this->assertEquals($expected->get_maildisplay(), $check->get_maildisplay());
        $this->assertEquals($expected->get_lang(), $check->get_lang());
        if ($checkresourcelink) {
            $this->assertEquals($expected->get_resourcelinkid(), $check->get_resourcelinkid());
        }
    }

    /**
     * Helper to assert that all the key elements of a user are present in the DB.
     *
     * @param user $expected the user whose values are deemed correct.
     */
    protected function assert_user_db_values(user $expected) {
        global $DB;
        $sql = "SELECT u.username, u.firstname, u.lastname, u.email, u.city, u.country, u.institution, u.timezone,
                       u.maildisplay, u.mnethostid, u.confirmed, u.lang, u.auth
                  FROM {enrol_lti_users} lu
                  JOIN {user} u
                    ON (lu.userid = u.id)
                 WHERE lu.id = :id";
        $userrecord = $DB->get_record_sql($sql, ['id' => $expected->get_id()]);
        $this->assertEquals($expected->get_city(), $userrecord->city);
        $this->assertEquals($expected->get_country(), $userrecord->country);
        $this->assertEquals($expected->get_institution(), $userrecord->institution);
        $this->assertEquals($expected->get_timezone(), $userrecord->timezone);
        $this->assertEquals($expected->get_maildisplay(), $userrecord->maildisplay);
        $this->assertEquals($expected->get_lang(), $userrecord->lang);

        $ltiuserrecord = $DB->get_record('enrol_lti_users', ['id' => $expected->get_id()]);
        $this->assertEquals($expected->get_id(), $ltiuserrecord->id);
        $this->assertEquals($expected->get_sourceid(), $ltiuserrecord->sourceid);
        $this->assertEquals($expected->get_resourceid(), $ltiuserrecord->toolid);
        $this->assertEquals($expected->get_lastgrade(), $ltiuserrecord->lastgrade);

        if ($expected->get_resourcelinkid()) {
            $sql = "SELECT rl.id
                      FROM {enrol_lti_users} lu
                      JOIN {enrol_lti_user_resource_link} rlj
                        ON (lu.id = rlj.ltiuserid)
                      JOIN {enrol_lti_resource_link} rl
                        ON (rl.id = rlj.resourcelinkid)
                     WHERE lu.id = :id";
            $resourcelinkrecord = $DB->get_record_sql($sql, ['id' => $expected->get_id()]);
            $this->assertEquals($expected->get_resourcelinkid(), $resourcelinkrecord->id);
        }
    }

    /**
     * Tests adding a user to the store, assuming that the user has been created using the default 'user default values'.
     *
     * @covers ::save
     */
    public function test_save_new_unchanged_user_defaults(): void {
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $sink = $this->redirectEvents();
        $saveduser = $userrepo->save($user);
        $events = $sink->get_events();
        $sink->close();

        $this->assertIsInt($saveduser->get_id());
        $this->assert_same_user_values($user, $saveduser, true);
        $this->assert_user_db_values($saveduser);
        // No change to underlying user: city, etc. take on default values matching those of the existing user record.
        $this->assertEmpty($events);
    }

    /**
     * Tests adding a user to the store, assuming that the user has been created using modified 'user default values'.
     *
     * @covers ::save
     */
    public function test_save_new_changed_user_defaults(): void {
        $this->resetAfterTest();
        $user = $this->generate_user(1, ['city' => 'Perth']);
        $userrepo = new user_repository();
        $sink = $this->redirectEvents();
        $saveduser = $userrepo->save($user);
        $events = $sink->get_events();
        $sink->close();

        $this->assertIsInt($saveduser->get_id());
        $this->assert_same_user_values($user, $saveduser, true);
        $this->assert_user_db_values($saveduser);
        // The underlying user record will change: city ('Perth') differs from that of the existing user ('').
        $this->assertInstanceOf(\core\event\user_updated::class, $events[0]);
    }

    /**
     * Test saving an existing user instance.
     *
     * @covers ::save
     */
    public function test_save_existing(): void {
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $sink = $this->redirectEvents();
        $saveduser = $userrepo->save($user);
        $events = $sink->get_events();
        $sink->close();
        $this->assertEmpty($events); // No event for the first save, since the underlying user record is unchanged.

        $saveduser->set_city('New City');
        $saveduser->set_country('NZ');
        $saveduser->set_lastgrade(99.99999999);
        $sink = $this->redirectEvents();
        $saveduser2 = $userrepo->save($saveduser);
        $events = $sink->get_events();
        $sink->close();

        $this->assertEquals($saveduser->get_id(), $saveduser2->get_id());
        $this->assert_same_user_values($saveduser, $saveduser2, true);
        $this->assert_user_db_values($saveduser2);
        // The underlying user record will change now, since city and country have changed.
        $this->assertInstanceOf(\core\event\user_updated::class, $events[0]);
    }

    /**
     * Test saving an instance which exists by id, but has a different localid to the data in the store.
     *
     * @covers ::save
     */
    public function test_save_existing_localid_mismatch(): void {
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $saveduser = $userrepo->save($user);

        $user2 = user::create(
            $saveduser->get_resourceid(),
            999999,
            $saveduser->get_deploymentid(),
            $saveduser->get_sourceid(),
            $saveduser->get_lang(),
            $saveduser->get_timezone(),
            '',
            '',
            '',
            null,
            null,
            null,
            null,
            $saveduser->get_id()
        );
        $this->expectException(\coding_exception::class);
        $this->expectExceptionMessage("Cannot update user mapping. LTI user '{$saveduser->get_id()}' is already mapped " .
            "to user '{$saveduser->get_localid()}' and can't be associated with another user '999999'.");
        $userrepo->save($user2);
    }

    /**
     * Test trying to save a user with an id that is invalid.
     *
     * @covers ::save
     */
    public function test_save_stale_id(): void {
        global $CFG;
        $this->resetAfterTest();
        $instructoruser = $this->getDataGenerator()->create_user();
        $userrepo = new user_repository();
        $user = user::create(
            4,
            $instructoruser->id,
            5,
            'source-id-123',
            $CFG->lang,
            '99',
            '',
            '',
            '',
            null,
            null,
            null,
            null,
            999999
        );

        $this->expectException(\coding_exception::class);
        $this->expectExceptionMessage("Cannot save lti user with id '999999'. The record does not exist.");
        $userrepo->save($user);
    }

    /**
     * Verify that trying to save a stale object results in an exception referring to unique constraint violation.
     *
     * @covers ::save
     */
    public function test_save_uniqueness_constraint(): void {
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $userrepo->save($user);

        $this->expectException(\coding_exception::class);
        $this->expectExceptionMessageMatches("/Cannot create duplicate LTI user '[a-z0-9_]*' for resource '[0-9]*'/");
        $userrepo->save($user);
    }

    /**
     * Test finding a user instance by id.
     *
     * @covers ::find
     */
    public function test_find(): void {
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $saveduser = $userrepo->save($user);

        $founduser = $userrepo->find($saveduser->get_id());
        $this->assertIsInt($founduser->get_id());
        $this->assert_same_user_values($saveduser, $founduser, false);

        $this->assertNull($userrepo->find(0));
    }

    /**
     * Test finding all of users associated with a given published resource.
     *
     * @covers ::find_by_resource
     */
    public function test_find_by_resource(): void {
        global $CFG;
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $saveduser = $userrepo->save($user);
        $instructoruser = $this->getDataGenerator()->create_user();

        $user2 = user::create(
            $saveduser->get_resourceid(),
            $instructoruser->id,
            $saveduser->get_deploymentid(),
            'another-user-123',
            $CFG->lang,
            '99',
            'Perth',
            'AU',
            'An Example Institution',
            2
        );
        $saveduser2 = $userrepo->save($user2);
        $savedusers = [$saveduser->get_id() => $saveduser, $saveduser2->get_id() => $saveduser2];

        $foundusers = $userrepo->find_by_resource($saveduser->get_resourceid());
        $this->assertCount(2, $foundusers);
        foreach ($foundusers as $founduser) {
            $this->assert_same_user_values($savedusers[$founduser->get_id()], $founduser);
        }
    }

    /**
     * Test that users can be found based on their resource_link association.
     *
     * @covers ::find_by_resource_link
     */
    public function test_find_by_resource_link(): void {
        global $CFG;
        $this->resetAfterTest();
        $user = $this->generate_user();
        $user->set_resourcelinkid(33);
        $userrepo = new user_repository();
        $saveduser = $userrepo->save($user);

        $instructoruser = $this->getDataGenerator()->create_user();
        $user2 = user::create(
            $saveduser->get_resourceid(),
            $instructoruser->id,
            $saveduser->get_deploymentid(),
            'another-user-123',
            $CFG->lang,
            '99',
            'Perth',
            'AU',
            'An Example Institution',
            2,
            null,
            null,
            33
        );
        $saveduser2 = $userrepo->save($user2);
        $savedusers = [$saveduser->get_id() => $saveduser, $saveduser2->get_id() => $saveduser2];

        $foundusers = $userrepo->find_by_resource_link(33);
        $this->assertCount(2, $foundusers);
        foreach ($foundusers as $founduser) {
            $this->assert_same_user_values($savedusers[$founduser->get_id()], $founduser);
        }
    }

    /**
     * Test checking existence of a user instance, based on id.
     *
     * @covers ::exists
     */
    public function test_exists(): void {
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $saveduser = $userrepo->save($user);

        $this->assertTrue($userrepo->exists($saveduser->get_id()));
        $this->assertFalse($userrepo->exists(-50));
    }

    /**
     * Test deleting a user instance, based on id.
     *
     * @covers ::delete
     */
    public function test_delete(): void {
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $saveduser = $userrepo->save($user);
        $this->assertTrue($userrepo->exists($saveduser->get_id()));

        $userrepo->delete($saveduser->get_id());
        $this->assertFalse($userrepo->exists($saveduser->get_id()));

        global $DB;
        $this->assertFalse($DB->record_exists('enrol_lti_users', ['id' => $saveduser->get_id()]));
        $this->assertFalse($DB->record_exists('enrol_lti_user_resource_link', ['ltiuserid' => $saveduser->get_id()]));
        $this->assertTrue($DB->record_exists('user', ['id' => $saveduser->get_localid()]));

        $this->assertNull($userrepo->delete($saveduser->get_id()));
    }

    /**
     * Test deleting a collection of lti user instances by deployment.
     *
     * @covers ::delete_by_deployment
     */
    public function test_delete_by_deployment(): void {
        global $CFG;
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $saveduser = $userrepo->save($user);
        $instructoruser = $this->getDataGenerator()->create_user();
        $instructor2user = $this->getDataGenerator()->create_user();

        $user2 = user::create(
            $saveduser->get_resourceid(),
            $instructoruser->id,
            $saveduser->get_deploymentid(),
            'another-user-123',
            $CFG->lang,
            '99',
            'Perth',
            'AU',
            'An Example Institution',
        );
        $saveduser2 = $userrepo->save($user2);

        $user3 = user::create(
            $saveduser->get_resourceid(),
            $instructor2user->id,
            $saveduser->get_deploymentid() + 1,
            'another-user-678',
            $CFG->lang,
            '99',
            'Melbourne',
            'AU',
            'An Example Institution',
        );
        $saveduser3 = $userrepo->save($user3);
        $this->assertTrue($userrepo->exists($saveduser->get_id()));
        $this->assertTrue($userrepo->exists($saveduser2->get_id()));
        $this->assertTrue($userrepo->exists($saveduser3->get_id()));

        $userrepo->delete_by_deployment($saveduser->get_deploymentid());
        $this->assertFalse($userrepo->exists($saveduser->get_id()));
        $this->assertFalse($userrepo->exists($saveduser2->get_id()));
        $this->assertTrue($userrepo->exists($saveduser3->get_id()));
    }

    /**
     * Verify a user who has been deleted can be re-saved to the repository and matched to an existing local user.
     *
     * @covers ::save
     */
    public function test_save_deleted(): void {
        $this->resetAfterTest();
        $user = $this->generate_user();
        $userrepo = new user_repository();
        $saveduser = $userrepo->save($user);

        $userrepo->delete($saveduser->get_id());
        $this->assertFalse($userrepo->exists($saveduser->get_id()));

        $saveduser2 = $userrepo->save($user);
        $this->assertEquals($saveduser->get_localid(), $saveduser2->get_localid());
        $this->assertNotEquals($saveduser->get_id(), $saveduser2->get_id());
    }

    /**
     * Test confirming that any associated legacy lti user records are not returned by the repository.
     *
     * This test ensures that any enrolment methods (resources) updated in-place from legacy LTI to 1.3 only return LTI 1.3 users.
     *
     * @covers ::find
     * @covers ::find_single_user_by_resource
     * @covers ::find_by_resource
     */
    public function test_find_filters_legacy_lti_users(): void {
        $this->resetAfterTest();
        global $DB;
        $user = $this->getDataGenerator()->create_user();
        $course = $this->getDataGenerator()->create_course();
        $resource = $this->getDataGenerator()->create_lti_tool((object)['courseid' => $course->id]);
        $ltiuserdata = [
            'userid' => $user->id,
            'toolid' => $resource->id,
            'sourceid' => '1001',
        ];
        $ltiuserid = $DB->insert_record('enrol_lti_users', $ltiuserdata);
        $userrepo = new user_repository();

        $this->assertNull($userrepo->find($ltiuserid));
        $this->assertNull($userrepo->find_single_user_by_resource($user->id, $resource->id));
        $this->assertEmpty($userrepo->find_by_resource($resource->id));

        // Set deploymentid, indicating the user originated from an LTI 1.3 launch and should now be returned.
        $ltiuserdata['id'] = $ltiuserid;
        $ltiuserdata['ltideploymentid'] = '234';
        $DB->update_record('enrol_lti_users', $ltiuserdata);

        $this->assertInstanceOf(user::class, $userrepo->find($ltiuserid));
        $this->assertInstanceOf(user::class, $userrepo->find_single_user_by_resource($user->id, $resource->id));
        $ltiusers = $userrepo->find_by_resource($resource->id);
        $this->assertCount(1, $ltiusers);
        $this->assertInstanceOf(user::class, reset($ltiusers));
    }
}
