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

/**
 * H5P activity manager class
 *
 * @package    mod_h5pactivity
 * @since      Moodle 3.9
 * @copyright  2020 Ferran Recio <ferran@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace mod_h5pactivity\local;

use mod_h5pactivity\local\report\participants;
use mod_h5pactivity\local\report\attempts;
use mod_h5pactivity\local\report\results;
use context_module;
use cm_info;
use moodle_recordset;
use core_user;
use stdClass;
use core\dml\sql_join;
use mod_h5pactivity\event\course_module_viewed;

/**
 * Class manager for H5P activity
 *
 * @package    mod_h5pactivity
 * @since      Moodle 3.9
 * @copyright  2020 Ferran Recio <ferran@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class manager {

    /** No automathic grading using attempt results. */
    const GRADEMANUAL = 0;

    /** Use highest attempt results for grading. */
    const GRADEHIGHESTATTEMPT = 1;

    /** Use average attempt results for grading. */
    const GRADEAVERAGEATTEMPT = 2;

    /** Use last attempt results for grading. */
    const GRADELASTATTEMPT = 3;

    /** Use first attempt results for grading. */
    const GRADEFIRSTATTEMPT = 4;

    /** Participants cannot review their own attempts. */
    const REVIEWNONE = 0;

    /** Participants can review their own attempts when have one attempt completed. */
    const REVIEWCOMPLETION = 1;

    /** @var stdClass course_module record. */
    private $instance;

    /** @var context_module the current context. */
    private $context;

    /** @var cm_info course_modules record. */
    private $coursemodule;

    /**
     * Class contructor.
     *
     * @param cm_info $coursemodule course module info object
     * @param stdClass $instance H5Pactivity instance object.
     */
    public function __construct(cm_info $coursemodule, stdClass $instance) {
        $this->coursemodule = $coursemodule;
        $this->instance = $instance;
        $this->context = context_module::instance($coursemodule->id);
        $this->instance->cmidnumber = $coursemodule->idnumber;
    }

    /**
     * Create a manager instance from an instance record.
     *
     * @param stdClass $instance a h5pactivity record
     * @return manager
     */
    public static function create_from_instance(stdClass $instance): self {
        $coursemodule = get_coursemodule_from_instance('h5pactivity', $instance->id);
        // Ensure that $this->coursemodule is a cm_info object.
        $coursemodule = cm_info::create($coursemodule);
        return new self($coursemodule, $instance);
    }

    /**
     * Create a manager instance from an course_modules record.
     *
     * @param stdClass|cm_info $coursemodule a h5pactivity record
     * @return manager
     */
    public static function create_from_coursemodule($coursemodule): self {
        global $DB;
        // Ensure that $this->coursemodule is a cm_info object.
        $coursemodule = cm_info::create($coursemodule);
        $instance = $DB->get_record('h5pactivity', ['id' => $coursemodule->instance], '*', MUST_EXIST);
        return new self($coursemodule, $instance);
    }

    /**
     * Return the available grading methods.
     * @return string[] an array "option value" => "option description"
     */
    public static function get_grading_methods(): array {
        return [
            self::GRADEHIGHESTATTEMPT => get_string('grade_highest_attempt', 'mod_h5pactivity'),
            self::GRADEAVERAGEATTEMPT => get_string('grade_average_attempt', 'mod_h5pactivity'),
            self::GRADELASTATTEMPT => get_string('grade_last_attempt', 'mod_h5pactivity'),
            self::GRADEFIRSTATTEMPT => get_string('grade_first_attempt', 'mod_h5pactivity'),
            self::GRADEMANUAL => get_string('grade_manual', 'mod_h5pactivity'),
        ];
    }

    /**
     * Return the selected attempt criteria.
     * @return string[] an array "grademethod value", "attempt description"
     */
    public function get_selected_attempt(): array {
        $types = [
            self::GRADEHIGHESTATTEMPT => get_string('attempt_highest', 'mod_h5pactivity'),
            self::GRADEAVERAGEATTEMPT => get_string('attempt_average', 'mod_h5pactivity'),
            self::GRADELASTATTEMPT => get_string('attempt_last', 'mod_h5pactivity'),
            self::GRADEFIRSTATTEMPT => get_string('attempt_first', 'mod_h5pactivity'),
            self::GRADEMANUAL => get_string('attempt_none', 'mod_h5pactivity'),
        ];
        if ($this->instance->enabletracking) {
            $key = $this->instance->grademethod;
        } else {
            $key = self::GRADEMANUAL;
        }
        return [$key, $types[$key]];
    }

    /**
     * Return the available review modes.
     *
     * @return string[] an array "option value" => "option description"
     */
    public static function get_review_modes(): array {
        return [
            self::REVIEWCOMPLETION => get_string('review_on_completion', 'mod_h5pactivity'),
            self::REVIEWNONE => get_string('review_none', 'mod_h5pactivity'),
        ];
    }

    /**
     * Check if tracking is enabled in a particular h5pactivity for a specific user.
     *
     * @return bool if tracking is enabled in this activity
     */
    public function is_tracking_enabled(): bool {
        return $this->instance->enabletracking;
    }

    /**
     * Check if the user has permission to submit a particular h5pactivity for a specific user.
     *
     * @param stdClass|null $user user record (default $USER)
     * @return bool if the user has permission to submit in this activity
     */
    public function can_submit(?stdClass $user = null): bool {
        global $USER;

        if (empty($user)) {
            $user = $USER;
        }
        return has_capability('mod/h5pactivity:submit', $this->context, $user, false);
    }

    /**
     * Check if a user can see the activity attempts list.
     *
     * @param stdClass|null $user user record (default $USER)
     * @return bool if the user can see the attempts link
     */
    public function can_view_all_attempts(?stdClass $user = null): bool {
        global $USER;
        if (!$this->instance->enabletracking) {
            return false;
        }
        if (empty($user)) {
            $user = $USER;
        }
        return has_capability('mod/h5pactivity:reviewattempts', $this->context, $user);
    }

    /**
     * Check if a user can see own attempts.
     *
     * @param stdClass|null $user user record (default $USER)
     * @return bool if the user can see the own attempts link
     */
    public function can_view_own_attempts(?stdClass $user = null): bool {
        global $USER;
        if (!$this->instance->enabletracking) {
            return false;
        }
        if (empty($user)) {
            $user = $USER;
        }
        if (has_capability('mod/h5pactivity:reviewattempts', $this->context, $user, false)) {
            return true;
        }
        if ($this->instance->reviewmode == self::REVIEWNONE) {
            return false;
        }
        if ($this->instance->reviewmode == self::REVIEWCOMPLETION) {
            return true;
        }
        return false;

    }

    /**
     * Return a relation of userid and the valid attempt's scaled score.
     *
     * The returned elements contain a record
     * of userid, scaled value, attemptid and timemodified. In case the grading method is "GRADEAVERAGEATTEMPT"
     * the attemptid will be zero. In case that tracking is disabled or grading method is "GRADEMANUAL"
     * the method will return null.
     *
     * @param int $userid a specific userid or 0 for all user attempts.
     * @return array|null of userid, scaled value and, if exists, the attempt id
     */
    public function get_users_scaled_score(int $userid = 0): ?array {
        global $DB;

        $scaled = [];
        if (!$this->instance->enabletracking) {
            return null;
        }

        if ($this->instance->grademethod == self::GRADEMANUAL) {
            return null;
        }

        $sql = '';

        // General filter.
        $where = 'a.h5pactivityid = :h5pactivityid';
        $params['h5pactivityid'] = $this->instance->id;

        if ($userid) {
            $where .= ' AND a.userid = :userid';
            $params['userid'] = $userid;
        }

        // Average grading needs aggregation query.
        if ($this->instance->grademethod == self::GRADEAVERAGEATTEMPT) {
            $sql = "SELECT a.userid, AVG(a.scaled) AS scaled, 0 AS attemptid, MAX(timemodified) AS timemodified
                      FROM {h5pactivity_attempts} a
                     WHERE $where AND a.completion = 1
                  GROUP BY a.userid";
        }

        if (empty($sql)) {
            // Decide which attempt is used for the calculation.
            $condition = [
                self::GRADEHIGHESTATTEMPT => "a.scaled < b.scaled",
                self::GRADELASTATTEMPT => "a.attempt < b.attempt",
                self::GRADEFIRSTATTEMPT => "a.attempt > b.attempt",
            ];
            $join = $condition[$this->instance->grademethod] ?? $condition[self::GRADEHIGHESTATTEMPT];

            $sql = "SELECT a.userid, a.scaled, MAX(a.id) AS attemptid, MAX(a.timemodified) AS timemodified
                      FROM {h5pactivity_attempts} a
                 LEFT JOIN {h5pactivity_attempts} b ON a.h5pactivityid = b.h5pactivityid
                           AND a.userid = b.userid AND b.completion = 1
                           AND $join
                     WHERE $where AND b.id IS NULL AND a.completion = 1
                  GROUP BY a.userid, a.scaled";
        }

        return $DB->get_records_sql($sql, $params);
    }

    /**
     * Count the activity completed attempts.
     *
     * If no user is provided the method will count all active users attempts.
     * Check get_active_users_join PHPdoc to a more detailed description of "active users".
     *
     * @param int|null $userid optional user id (default null)
     * @return int the total amount of attempts
     */
    public function count_attempts(?int $userid = null): int {
        global $DB;

        // Counting records is enough for one user.
        if ($userid) {
            $params['userid'] = $userid;
            $params = [
                'h5pactivityid' => $this->instance->id,
                'userid' => $userid,
                'completion' => 1,
            ];
            return $DB->count_records('h5pactivity_attempts', $params);
        }

        $usersjoin = $this->get_active_users_join();

        // Final SQL.
        return $DB->count_records_sql(
            "SELECT COUNT(*)
               FROM {user} u $usersjoin->joins
              WHERE $usersjoin->wheres",
            array_merge($usersjoin->params)
        );
    }

    /**
     * Return the join to collect all activity active users.
     *
     * The concept of active user is relative to the activity permissions. All users with
     * "mod/h5pactivity:view" are potential users but those with "mod/h5pactivity:reviewattempts"
     * are evaluators and they don't count as valid submitters.
     *
     * Note that, in general, the active list has the same effect as checking for "mod/h5pactivity:submit"
     * but submit capability cannot be used because is a write capability and does not apply to frozen contexts.
     *
     * @since Moodle 3.11
     * @param bool $allpotentialusers if true, the join will return all active users, not only the ones with attempts.
     * @param int|bool $currentgroup False if groups not used, 0 for all groups, group id (int) to filter by specific group
     * @return sql_join the active users attempts join
     */
    public function get_active_users_join(bool $allpotentialusers = false, $currentgroup = false): sql_join {

        // Only valid users counts. By default, all users with submit capability are considered potential ones.
        $context = $this->get_context();
        $coursemodule = $this->get_coursemodule();

        // Ensure user can view users from all groups.
        if ($currentgroup === 0 && $coursemodule->effectivegroupmode == SEPARATEGROUPS
                && !has_capability('moodle/site:accessallgroups', $context)) {

            return new sql_join('', '1=2', [], true);
        }

        // We want to present all potential users.
        $capjoin = get_enrolled_with_capabilities_join($context, '', 'mod/h5pactivity:view', $currentgroup);
        if ($capjoin->cannotmatchanyrows) {
            return $capjoin;
        }

        // But excluding all reviewattempts users converting a capabilities join into left join.
        $reviewersjoin = get_with_capability_join($context, 'mod/h5pactivity:reviewattempts', 'u.id');
        if ($reviewersjoin->cannotmatchanyrows) {
            return $capjoin;
        }

        if (str_contains($reviewersjoin->joins, 'ra')) {
            $capjoin = new sql_join(
                $capjoin->joins . "\n LEFT " . str_replace('ra', 'reviewer', $reviewersjoin->joins),
                $capjoin->wheres . " AND reviewer.userid IS NULL",
                $capjoin->params
            );
        }

        if ($allpotentialusers) {
            return $capjoin;
        }

        // Add attempts join.
        $where = "ha.h5pactivityid = :h5pactivityid AND ha.completion = :completion";
        $params = [
            'h5pactivityid' => $this->instance->id,
            'completion' => 1,
        ];

        return new sql_join(
            $capjoin->joins . "\n JOIN {h5pactivity_attempts} ha ON ha.userid = u.id",
            $capjoin->wheres . " AND $where",
            array_merge($capjoin->params, $params)
        );
    }

    /**
     * Return an array of all users and it's total attempts.
     *
     * Note: this funciton only returns the list of users with attempts,
     * it does not check all participants.
     *
     * @return array indexed count userid => total number of attempts
     */
    public function count_users_attempts(): array {
        global $DB;
        $params = [
            'h5pactivityid' => $this->instance->id,
        ];
        $sql = "SELECT userid, count(*)
                  FROM {h5pactivity_attempts}
                 WHERE h5pactivityid = :h5pactivityid
                 GROUP BY userid";
        return $DB->get_records_sql_menu($sql, $params);
    }

    /**
     * Return the current context.
     *
     * @return context_module
     */
    public function get_context(): context_module {
        return $this->context;
    }

    /**
     * Return the current instance.
     *
     * @return stdClass the instance record
     */
    public function get_instance(): stdClass {
        return $this->instance;
    }

    /**
     * Return the current cm_info.
     *
     * @return cm_info the course module
     */
    public function get_coursemodule(): cm_info {
        return $this->coursemodule;
    }

    /**
     * Return the specific grader object for this activity.
     *
     * @return grader
     */
    public function get_grader(): grader {
        $idnumber = $this->coursemodule->idnumber ?? '';
        return new grader($this->instance, $idnumber);
    }

    /**
     * Return the suitable report to show the attempts.
     *
     * This method controls the access to the different reports
     * the activity have.
     *
     * @param int $userid an opional userid to show
     * @param int $attemptid an optional $attemptid to show
     * @param int|bool $currentgroup False if groups not used, 0 for all groups, group id (int) to filter by specific group
     * @return report|null available report (or null if no report available)
     */
    public function get_report(?int $userid = null, ?int $attemptid = null, $currentgroup = false): ?report {
        global $USER, $CFG;

        require_once("{$CFG->dirroot}/user/lib.php");

        // If tracking is disabled, no reports are available.
        if (!$this->instance->enabletracking) {
            return null;
        }

        $attempt = null;
        if ($attemptid) {
            $attempt = $this->get_attempt($attemptid);
            if (!$attempt) {
                return null;
            }
            // If we have and attempt we can ignore the provided $userid.
            $userid = $attempt->get_userid();
        }

        if ($this->can_view_all_attempts()) {
            $user = core_user::get_user($userid);

            // Ensure user can view the attempt of specific userid, respecting access checks.
            if ($user && $user->id != $USER->id) {
                $course = get_course($this->coursemodule->course);
                if (!groups_user_groups_visible($course, $user->id, $this->coursemodule)) {
                    return null;
                }
            }
        } else if ($this->can_view_own_attempts()) {
            $user = core_user::get_user($USER->id);
            if ($userid && $user->id != $userid) {
                return null;
            }
        } else {
            return null;
        }

        // Only enrolled users has reports.
        if ($user && !is_enrolled($this->context, $user, 'mod/h5pactivity:view')) {
            return null;
        }

        // Create the proper report.
        if ($user && $attempt) {
            return new results($this, $user, $attempt);
        } else if ($user) {
            return new attempts($this, $user);
        }
        return new participants($this, $currentgroup);
    }

    /**
     * Return a single attempt.
     *
     * @param int $attemptid the attempt id
     * @return attempt
     */
    public function get_attempt(int $attemptid): ?attempt {
        global $DB;
        $record = $DB->get_record('h5pactivity_attempts', [
            'id' => $attemptid,
            'h5pactivityid' => $this->instance->id,
        ]);
        if (!$record) {
            return null;
        }
        return new attempt($record);
    }

    /**
     * Return an array of all user attempts (including incompleted)
     *
     * @param int $userid the user id
     * @return attempt[]
     */
    public function get_user_attempts(int $userid): array {
        global $DB;
        $records = $DB->get_records(
            'h5pactivity_attempts',
            ['userid' => $userid, 'h5pactivityid' => $this->instance->id],
            'id ASC'
        );
        if (!$records) {
            return [];
        }
        $result = [];
        foreach ($records as $record) {
            $result[] = new attempt($record);
        }
        return $result;
    }

    /**
     * Trigger module viewed event and set the module viewed for completion.
     *
     * @param stdClass $course course object
     * @return void
     */
    public function set_module_viewed(stdClass $course): void {
        global $CFG;
        require_once($CFG->libdir . '/completionlib.php');

        // Trigger module viewed event.
        $event = course_module_viewed::create([
            'objectid' => $this->instance->id,
            'context' => $this->context
        ]);
        $event->add_record_snapshot('course', $course);
        $event->add_record_snapshot('course_modules', $this->coursemodule);
        $event->add_record_snapshot('h5pactivity', $this->instance);
        $event->trigger();

        // Completion.
        $completion = new \completion_info($course);
        $completion->set_module_viewed($this->coursemodule);
    }
}
