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

/**
 * Helper functions for the quiz reports.
 *
 * @package   mod_quiz
 * @copyright 2008 Jamie Pratt
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */


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

require_once($CFG->dirroot . '/mod/quiz/lib.php');
require_once($CFG->libdir . '/filelib.php');

use mod_quiz\question\display_options;

/**
 * Takes an array of objects and constructs a multidimensional array keyed by
 * the keys it finds on the object.
 * @param array $datum an array of objects with properties on the object
 * including the keys passed as the next param.
 * @param array $keys Array of strings with the names of the properties on the
 * objects in datum that you want to index the multidimensional array by.
 * @param bool $keysunique If there is not only one object for each
 * combination of keys you are using you should set $keysunique to true.
 * Otherwise all the object will be added to a zero based array. So the array
 * returned will have count($keys) + 1 indexs.
 * @return array multidimensional array properly indexed.
 */
function quiz_report_index_by_keys($datum, $keys, $keysunique = true) {
    if (!$datum) {
        return [];
    }
    $key = array_shift($keys);
    $datumkeyed = [];
    foreach ($datum as $data) {
        if ($keys || !$keysunique) {
            $datumkeyed[$data->{$key}][]= $data;
        } else {
            $datumkeyed[$data->{$key}]= $data;
        }
    }
    if ($keys) {
        foreach ($datumkeyed as $datakey => $datakeyed) {
            $datumkeyed[$datakey] = quiz_report_index_by_keys($datakeyed, $keys, $keysunique);
        }
    }
    return $datumkeyed;
}

function quiz_report_unindex($datum) {
    if (!$datum) {
        return $datum;
    }
    $datumunkeyed = [];
    foreach ($datum as $value) {
        if (is_array($value)) {
            $datumunkeyed = array_merge($datumunkeyed, quiz_report_unindex($value));
        } else {
            $datumunkeyed[] = $value;
        }
    }
    return $datumunkeyed;
}

/**
 * Are there any questions in this quiz?
 * @param int $quizid the quiz id.
 */
function quiz_has_questions($quizid) {
    global $DB;
    return $DB->record_exists('quiz_slots', ['quizid' => $quizid]);
}

/**
 * Get the slots of real questions (not descriptions) in this quiz, in order.
 * @param stdClass $quiz the quiz.
 * @return array of slot => objects with fields
 *      ->slot, ->id, ->qtype, ->length, ->number, ->maxmark, ->category (for random questions).
 */
function quiz_report_get_significant_questions($quiz) {
    $quizobj = mod_quiz\quiz_settings::create($quiz->id);
    $structure = \mod_quiz\structure::create_for_quiz($quizobj);
    $slots = $structure->get_slots();

    $qsbyslot = [];
    $number = 1;
    foreach ($slots as $slot) {
        // Ignore 'questions' of zero length.
        if ($slot->length == 0) {
            continue;
        }

        $slotreport = new \stdClass();
        $slotreport->slot = $slot->slot;
        $slotreport->id = $slot->questionid;
        $slotreport->qtype = $slot->qtype;
        $slotreport->length = $slot->length;
        $slotreport->number = $number;
        $slotreport->displaynumber = $slot->displaynumber ?? $number;
        $number += $slot->length;
        $slotreport->maxmark = $slot->maxmark;
        $slotreport->category = $slot->category;

        $qsbyslot[$slotreport->slot] = $slotreport;
    }

    return $qsbyslot;
}

/**
 * @param stdClass $quiz the quiz settings.
 * @return bool whether, for this quiz, it is possible to filter attempts to show
 *      only those that gave the final grade.
 */
function quiz_report_can_filter_only_graded($quiz) {
    return $quiz->attempts != 1 && $quiz->grademethod != QUIZ_GRADEAVERAGE;
}

/**
 * This is a wrapper for {@link quiz_report_grade_method_sql} that takes the whole quiz object instead of just the grading method
 * as a param. See definition for {@link quiz_report_grade_method_sql} below.
 *
 * @param stdClass $quiz
 * @param string $quizattemptsalias sql alias for 'quiz_attempts' table
 * @return string sql to test if this is an attempt that will contribute towards the grade of the user
 */
function quiz_report_qm_filter_select($quiz, $quizattemptsalias = 'quiza') {
    if ($quiz->attempts == 1) {
        // This quiz only allows one attempt.
        return '';
    }
    return quiz_report_grade_method_sql($quiz->grademethod, $quizattemptsalias);
}

/**
 * Given a quiz grading method return sql to test if this is an
 * attempt that will be contribute towards the grade of the user. Or return an
 * empty string if the grading method is QUIZ_GRADEAVERAGE and thus all attempts
 * contribute to final grade.
 *
 * @param string $grademethod quiz grading method.
 * @param string $quizattemptsalias sql alias for 'quiz_attempts' table
 * @return string sql to test if this is an attempt that will contribute towards the graded of the user
 */
function quiz_report_grade_method_sql($grademethod, $quizattemptsalias = 'quiza') {
    switch ($grademethod) {
        case QUIZ_GRADEHIGHEST :
            return "($quizattemptsalias.state = 'finished' AND NOT EXISTS (
                           SELECT 1 FROM {quiz_attempts} qa2
                            WHERE qa2.quiz = $quizattemptsalias.quiz AND
                                qa2.userid = $quizattemptsalias.userid AND
                                 qa2.state = 'finished' AND (
                COALESCE(qa2.sumgrades, 0) > COALESCE($quizattemptsalias.sumgrades, 0) OR
               (COALESCE(qa2.sumgrades, 0) = COALESCE($quizattemptsalias.sumgrades, 0) AND qa2.attempt < $quizattemptsalias.attempt)
                                )))";

        case QUIZ_GRADEAVERAGE :
            return '';

        case QUIZ_ATTEMPTFIRST :
            return "(
                $quizattemptsalias.state IN ('finished', 'submitted')
                AND NOT EXISTS (
                    SELECT 1
                      FROM {quiz_attempts} qa2
                     WHERE qa2.quiz = $quizattemptsalias.quiz
                           AND qa2.userid = $quizattemptsalias.userid
                           AND qa2.state IN ('finished', 'submitted')
                           AND qa2.attempt < $quizattemptsalias.attempt
                )
            )";

        case QUIZ_ATTEMPTLAST :
            return "(
                $quizattemptsalias.state IN ('finished', 'submitted')
                AND NOT EXISTS (
                    SELECT 1
                      FROM {quiz_attempts} qa2
                     WHERE qa2.quiz = $quizattemptsalias.quiz
                           AND qa2.userid = $quizattemptsalias.userid
                           AND qa2.state IN ('finished', 'submitted')
                           AND qa2.attempt > $quizattemptsalias.attempt
                )
            )";
    }
}

/**
 * Get the number of students whose score was in a particular band for this quiz.
 * @param number $bandwidth the width of each band.
 * @param int $bands the number of bands
 * @param int $quizid the quiz id.
 * @param \core\dml\sql_join $usersjoins (joins, wheres, params) to get enrolled users
 * @return array band number => number of users with scores in that band.
 */
function quiz_report_grade_bands($bandwidth, $bands, $quizid, ?\core\dml\sql_join $usersjoins = null) {
    global $DB;
    if (!is_int($bands)) {
        debugging('$bands passed to quiz_report_grade_bands must be an integer. (' .
                gettype($bands) . ' passed.)', DEBUG_DEVELOPER);
        $bands = (int) $bands;
    }

    if ($usersjoins && !empty($usersjoins->joins)) {
        $userjoin = "JOIN {user} u ON u.id = qg.userid
                {$usersjoins->joins}";
        $usertest = $usersjoins->wheres;
        $params = $usersjoins->params;
    } else {
        $userjoin = '';
        $usertest = '1=1';
        $params = [];
    }
    $sql = "
SELECT band, COUNT(1)

FROM (
    SELECT FLOOR(qg.grade / :bandwidth) AS band
      FROM {quiz_grades} qg
    $userjoin
    WHERE $usertest AND qg.quiz = :quizid
) subquery

GROUP BY
    band

ORDER BY
    band";

    $params['quizid'] = $quizid;
    $params['bandwidth'] = $bandwidth;

    $data = $DB->get_records_sql_menu($sql, $params);

    // We need to create array elements with values 0 at indexes where there is no element.
    $data = $data + array_fill(0, $bands + 1, 0);
    ksort($data);

    // Place the maximum (perfect grade) into the last band i.e. make last
    // band for example 9 <= g <=10 (where 10 is the perfect grade) rather than
    // just 9 <= g <10.
    $data[$bands - 1] += $data[$bands];
    unset($data[$bands]);

    // See MDL-60632. When a quiz participant achieves an overall negative grade the chart fails to render.
    foreach ($data as $databand => $datanum) {
        if ($databand < 0) {
            $data["0"] += $datanum; // Add to band 0.
            unset($data[$databand]); // Remove entry below 0.
        }
    }

    return $data;
}

function quiz_report_highlighting_grading_method($quiz, $qmsubselect, $qmfilter) {
    if ($quiz->attempts == 1) {
        return '<p>' . get_string('onlyoneattemptallowed', 'quiz_overview') . '</p>';

    } else if (!$qmsubselect) {
        return '<p>' . get_string('allattemptscontributetograde', 'quiz_overview') . '</p>';

    } else if ($qmfilter) {
        return '<p>' . get_string('showinggraded', 'quiz_overview') . '</p>';

    } else {
        return '<p>' . get_string('showinggradedandungraded', 'quiz_overview',
                '<span class="gradedattempt">' . quiz_get_grading_option_name($quiz->grademethod) .
                '</span>') . '</p>';
    }
}

/**
 * Get the feedback text for a grade on this quiz. The feedback is
 * processed ready for display.
 *
 * @param float $grade a grade on this quiz.
 * @param int $quizid the id of the quiz object.
 * @return string the comment that corresponds to this grade (empty string if there is not one.
 */
function quiz_report_feedback_for_grade($grade, $quizid, $context) {
    global $DB;

    static $feedbackcache = [];

    if (!isset($feedbackcache[$quizid])) {
        $feedbackcache[$quizid] = $DB->get_records('quiz_feedback', ['quizid' => $quizid]);
    }

    // With CBM etc, it is possible to get -ve grades, which would then not match
    // any feedback. Therefore, we replace -ve grades with 0.
    $grade = max($grade, 0);

    $feedbacks = $feedbackcache[$quizid];
    $feedbackid = 0;
    $feedbacktext = '';
    $feedbacktextformat = FORMAT_MOODLE;
    foreach ($feedbacks as $feedback) {
        if ($feedback->mingrade <= $grade && $grade < $feedback->maxgrade) {
            $feedbackid = $feedback->id;
            $feedbacktext = $feedback->feedbacktext;
            $feedbacktextformat = $feedback->feedbacktextformat;
            break;
        }
    }

    // Clean the text, ready for display.
    $formatoptions = new stdClass();
    $formatoptions->noclean = true;
    $feedbacktext = file_rewrite_pluginfile_urls($feedbacktext, 'pluginfile.php',
            $context->id, 'mod_quiz', 'feedback', $feedbackid);
    $feedbacktext = format_text($feedbacktext, $feedbacktextformat, $formatoptions);

    return $feedbacktext;
}

/**
 * Format a number as a percentage out of $quiz->sumgrades
 * @param number $rawgrade the mark to format.
 * @param stdClass $quiz the quiz settings
 * @param bool $round whether to round the results ot $quiz->decimalpoints.
 */
function quiz_report_scale_summarks_as_percentage($rawmark, $quiz, $round = true) {
    if ($quiz->sumgrades == 0) {
        return '';
    }
    if (!is_numeric($rawmark)) {
        return $rawmark;
    }

    $mark = $rawmark * 100 / $quiz->sumgrades;
    if ($round) {
        $mark = quiz_format_grade($quiz, $mark);
    }

    return get_string('percents', 'moodle', $mark);
}

/**
 * Returns an array of reports to which the current user has access to.
 * @return array reports are ordered as they should be for display in tabs.
 */
function quiz_report_list($context) {
    global $DB;
    static $reportlist = null;
    if (!is_null($reportlist)) {
        return $reportlist;
    }

    $reports = $DB->get_records('quiz_reports', null, 'displayorder DESC', 'name, capability');
    $reportdirs = core_component::get_plugin_list('quiz');

    // Order the reports tab in descending order of displayorder.
    $reportcaps = [];
    foreach ($reports as $key => $report) {
        if (array_key_exists($report->name, $reportdirs)) {
            $reportcaps[$report->name] = $report->capability;
        }
    }

    // Add any other reports, which are on disc but not in the DB, on the end.
    foreach ($reportdirs as $reportname => $notused) {
        if (!isset($reportcaps[$reportname])) {
            $reportcaps[$reportname] = null;
        }
    }
    $reportlist = [];
    foreach ($reportcaps as $name => $capability) {
        if (empty($capability)) {
            $capability = 'mod/quiz:viewreports';
        }
        if (has_capability($capability, $context)) {
            $reportlist[] = $name;
        }
    }
    return $reportlist;
}

/**
 * Create a filename for use when downloading data from a quiz report. It is
 * expected that this will be passed to flexible_table::is_downloading, which
 * cleans the filename of bad characters and adds the file extension.
 * @param string $report the type of report.
 * @param string $courseshortname the course shortname.
 * @param string $quizname the quiz name.
 * @return string the filename.
 */
function quiz_report_download_filename($report, $courseshortname, $quizname) {
    return $courseshortname . '-' . format_string($quizname, true) . '-' . $report;
}

/**
 * Get the default report for the current user.
 * @param stdClass $context the quiz context.
 */
function quiz_report_default_report($context) {
    $reports = quiz_report_list($context);
    return reset($reports);
}

/**
 * Generate a message saying that this quiz has no questions, with a button to
 * go to the edit page, if the user has the right capability.
 * @param stdClass $quiz the quiz settings.
 * @param stdClass $cm the course_module object.
 * @param stdClass $context the quiz context.
 * @return string HTML to output.
 */
function quiz_no_questions_message($quiz, $cm, $context) {
    global $OUTPUT;

    $output = '';
    $output .= $OUTPUT->notification(get_string('noquestions', 'quiz'));
    if (has_capability('mod/quiz:manage', $context)) {
        $output .= $OUTPUT->single_button(new moodle_url('/mod/quiz/edit.php',
        ['cmid' => $cm->id]), get_string('editquiz', 'quiz'), 'get');
    }

    return $output;
}

/**
 * Should the grades be displayed in this report. That depends on the quiz
 * display options, and whether the quiz is graded.
 * @param stdClass $quiz the quiz settings.
 * @param context $context the quiz context.
 * @return bool
 */
function quiz_report_should_show_grades($quiz, context $context) {
    if ($quiz->timeclose && time() > $quiz->timeclose) {
        $when = display_options::AFTER_CLOSE;
    } else {
        $when = display_options::LATER_WHILE_OPEN;
    }
    $reviewoptions = display_options::make_from_quiz($quiz, $when);

    return quiz_has_grades($quiz) &&
            ($reviewoptions->marks >= question_display_options::MARK_AND_MAX ||
            has_capability('moodle/grade:viewhidden', $context));
}
