// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { Injectable } from '@angular/core';

import { CoreCanceledError } from '@classes/errors/cancelederror';
import { CoreError } from '@classes/errors/error';
import { CoreCourse } from '@features/course/services/course';
import { CoreNavigator } from '@services/navigator';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, Translate } from '@singletons';
import { AddonModQuizPreflightModalComponent } from '../components/preflight-modal/preflight-modal';
import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate';
import { AddonModQuizModuleHandlerService } from './handlers/module';
import {
    AddonModQuiz,
    AddonModQuizAttemptWSData,
    AddonModQuizCombinedReviewOptions,
    AddonModQuizGetQuizAccessInformationWSResponse,
    AddonModQuizQuizWSData,
} from './quiz';
import { AddonModQuizOffline } from './quiz-offline';
import {
    ADDON_MOD_QUIZ_IMMEDIATELY_AFTER_PERIOD,
    AddonModQuizAttemptStates,
    AddonModQuizDisplayOptionsAttemptStates,
} from '../constants';
import { QuestionDisplayOptionsMarks } from '@features/question/constants';
import { CoreGroups } from '@services/groups';
import { CoreTimeUtils } from '@services/utils/time';

/**
 * Helper service that provides some features for quiz.
 */
@Injectable({ providedIn: 'root' })
export class AddonModQuizHelperProvider {

    /**
     * Check if current user can review an attempt.
     *
     * @param quiz Quiz.
     * @param accessInfo Access info.
     * @param attempt Attempt.
     * @returns Whether user can review the attempt.
     */
    async canReviewAttempt(
        quiz: AddonModQuizQuizWSData,
        accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
        attempt: AddonModQuizAttemptWSData,
    ): Promise<boolean> {
        if (!this.hasReviewCapabilityForAttempt(quiz, accessInfo, attempt)) {
            return false;
        }

        if (attempt.userid !== CoreSites.getCurrentSiteUserId()) {
            return this.canReviewOtherUserAttempt(quiz, accessInfo, attempt);
        }

        if (!AddonModQuiz.isAttemptCompleted(attempt.state)) {
            // Cannot review own uncompleted attempts.
            return false;
        }

        if (attempt.preview && accessInfo.canpreview) {
            // A teacher can always review their own preview no matter the review options settings.
            return true;
        }

        if (!attempt.preview && accessInfo.canviewreports) {
            // Users who can see reports should be shown everything, except during preview.
            // In LMS, the capability 'moodle/grade:viewhidden' is also checked but the app doesn't have this info.
            return true;
        }

        const options = AddonModQuiz.getDisplayOptionsForQuiz(quiz, AddonModQuiz.getAttemptStateDisplayOption(quiz, attempt));

        return options.attempt;
    }

    /**
     * Check if current user can review another user attempt.
     *
     * @param quiz Quiz.
     * @param accessInfo Access info.
     * @param attempt Attempt.
     * @returns Whether user can review the attempt.
     */
    protected async canReviewOtherUserAttempt(
        quiz: AddonModQuizQuizWSData,
        accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
        attempt: AddonModQuizAttemptWSData,
    ): Promise<boolean> {
        if (!accessInfo.canviewreports) {
            return false;
        }

        try {
            const groupInfo = await CoreGroups.getActivityGroupInfo(quiz.coursemodule);
            if (groupInfo.canAccessAllGroups || !groupInfo.separateGroups) {
                return true;
            }

            // Check if the current user and the attempt's user share any group.
            if (!groupInfo.groups.length) {
                return false;
            }

            const attemptUserGroups = await CoreGroups.getUserGroupsInCourse(quiz.course, undefined, attempt.userid);

            return attemptUserGroups.some(attemptUserGroup => groupInfo.groups.find(group => attemptUserGroup.id === group.id));
        } catch {
            return false;
        }
    }

    /**
     * Get cannot review message.
     *
     * @param quiz Quiz.
     * @param attempt Attempt.
     * @param short Whether to use a short message or not.
     * @returns Cannot review message, or empty string if no message to display.
     */
    getCannotReviewMessage(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData, short = false): string {
        const displayOption = AddonModQuiz.getAttemptStateDisplayOption(quiz, attempt);

        let reviewFrom = 0;
        switch (displayOption) {
            case AddonModQuizDisplayOptionsAttemptStates.DURING:
                return '';

            case AddonModQuizDisplayOptionsAttemptStates.IMMEDIATELY_AFTER:
                // eslint-disable-next-line no-bitwise
                if ((quiz.reviewattempt ?? 0) & AddonModQuizDisplayOptionsAttemptStates.LATER_WHILE_OPEN) {
                    reviewFrom = (attempt.timefinish ?? Date.now()) + ADDON_MOD_QUIZ_IMMEDIATELY_AFTER_PERIOD;
                    break;
                }
                // Fall through.

            case AddonModQuizDisplayOptionsAttemptStates.LATER_WHILE_OPEN:
                // eslint-disable-next-line no-bitwise
                if (quiz.timeclose && ((quiz.reviewattempt ?? 0) & AddonModQuizDisplayOptionsAttemptStates.AFTER_CLOSE)) {
                    reviewFrom = quiz.timeclose;
                    break;
                }
        }

        if (reviewFrom) {
            return Translate.instant('addon.mod_quiz.noreviewuntil' + (short ? 'short' : ''), {
                $a: CoreTimeUtils.userDate(reviewFrom * 1000, short ? 'core.strftimedatetimeshort': undefined),
            });
        } else {
            return Translate.instant('addon.mod_quiz.noreviewattempt');
        }
    }

    /**
     * Validate a preflight data or show a modal to input the preflight data if required.
     * It calls AddonModQuizProvider.startAttempt if a new attempt is needed.
     *
     * @param quiz Quiz.
     * @param accessInfo Quiz access info.
     * @param preflightData Object where to store the preflight data.
     * @param options Options.
     * @returns Promise resolved when the preflight data is validated. The resolve param is the attempt.
     */
    async getAndCheckPreflightData(
        quiz: AddonModQuizQuizWSData,
        accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
        preflightData: Record<string, string>,
        options: GetAndCheckPreflightOptions = {},
    ): Promise<AddonModQuizAttemptWSData> {

        const rules = accessInfo?.activerulenames;

        // Check if the user needs to input preflight data.
        const preflightCheckRequired = await AddonModQuizAccessRuleDelegate.isPreflightCheckRequired(
            rules,
            quiz,
            options.attempt,
            options.prefetch,
            options.siteId,
        );

        if (preflightCheckRequired) {
            // Preflight check is required. Show a modal with the preflight form.
            const data = await this.getPreflightData(quiz, accessInfo, options);

            // Data entered by the user, add it to preflight data and check it again.
            Object.assign(preflightData, data);
        }

        // Get some fixed preflight data from access rules (data that doesn't require user interaction).
        await AddonModQuizAccessRuleDelegate.getFixedPreflightData(
            rules,
            quiz,
            preflightData,
            options.attempt,
            options.prefetch,
            options.siteId,
        );

        try {
            // All the preflight data is gathered, now validate it.
            return await this.validatePreflightData(quiz, accessInfo, preflightData, options);
        } catch (error) {

            if (options.prefetch) {
                throw error;
            } else if (options.retrying && !preflightCheckRequired) {
                // We're retrying after a failure, but the preflight check wasn't required.
                // This means there's something wrong with some access rule or user is offline and data isn't cached.
                // Don't retry again because it would lead to an infinite loop.
                throw error;
            }

            // Show error and ask for the preflight again.
            // Wait to show the error because we want it to be shown over the preflight modal.
            setTimeout(() => {
                CoreDomUtils.showErrorModalDefault(error, 'core.error', true);
            }, 100);

            return this.getAndCheckPreflightData(quiz, accessInfo, preflightData, {
                ...options,
                retrying: true,
            });
        }
    }

    /**
     * Get the preflight data from the user using a modal.
     *
     * @param quiz Quiz.
     * @param accessInfo Quiz access info.
     * @param options Options.
     * @returns Promise resolved with the preflight data. Rejected if user cancels.
     */
    async getPreflightData(
        quiz: AddonModQuizQuizWSData,
        accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
        options: GetPreflightOptions = {},
    ): Promise<Record<string, string>> {
        const notSupported: string[] = [];
        const rules = accessInfo?.activerulenames;

        // Check if there is any unsupported rule.
        rules.forEach((rule) => {
            if (!AddonModQuizAccessRuleDelegate.isAccessRuleSupported(rule)) {
                notSupported.push(rule);
            }
        });

        if (notSupported.length) {
            throw new CoreError(
                Translate.instant('addon.mod_quiz.errorrulesnotsupported') + ' ' + JSON.stringify(notSupported),
            );
        }

        // Create and show the modal.
        const modalData = await CoreDomUtils.openModal<Record<string, string>>({
            component: AddonModQuizPreflightModalComponent,
            componentProps: {
                title: options.title,
                quiz,
                attempt: options.attempt,
                prefetch: !!options.prefetch,
                siteId: options.siteId,
                rules: rules,
            },
        });

        if (!modalData) {
            throw new CoreCanceledError();
        }

        return modalData;
    }

    /**
     * Gets the mark string from a question HTML.
     * Example result: "Marked out of 1.00".
     *
     * @param html Question's HTML.
     * @returns Question's mark.
     */
    getQuestionMarkFromHtml(html: string): string | undefined {
        const element = CoreDomUtils.convertToElement(html);

        return CoreDomUtils.getContentsOfElement(element, '.grade');
    }

    /**
     * Get a quiz ID by attempt ID.
     *
     * @param attemptId Attempt ID.
     * @param options Other options.
     * @returns Promise resolved with the quiz ID.
     */
    async getQuizIdByAttemptId(attemptId: number, options: { cmId?: number; siteId?: string } = {}): Promise<number> {
        // Use getAttemptReview to retrieve the quiz ID.
        const reviewData = await AddonModQuiz.getAttemptReview(attemptId, options);

        if (reviewData.attempt.quiz) {
            return reviewData.attempt.quiz;
        }

        throw new CoreError('Cannot get quiz ID.');
    }

    /**
     * Handle a review link.
     *
     * @param attemptId Attempt ID.
     * @param page Page to load, -1 to all questions in same page.
     * @param quizId Quiz ID.
     * @param siteId Site ID. If not defined, current site.
     * @returns Promise resolved when done.
     */
    async handleReviewLink(attemptId: number, page?: number, quizId?: number, siteId?: string): Promise<void> {
        siteId = siteId || CoreSites.getCurrentSiteId();

        const modal = await CoreDomUtils.showModalLoading();

        try {
            if (!quizId) {
                quizId = await this.getQuizIdByAttemptId(attemptId, { siteId });
            }

            const module = await CoreCourse.getModuleBasicInfoByInstance(
                quizId,
                'quiz',
                { siteId, readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE },
            );

            // Go to the review page.
            await CoreNavigator.navigateToSitePath(
                `${AddonModQuizModuleHandlerService.PAGE_NAME}/${module.course}/${module.id}/review/${attemptId}`,
                {
                    params: {
                        page: page == undefined || isNaN(page) ? -1 : page,
                    },
                    siteId,
                },
            );
        } catch (error) {
            CoreDomUtils.showErrorModalDefault(error, 'An error occurred while loading the required data.');
        } finally {
            modal.dismiss();
        }
    }

    /**
     * Check if current user has the necessary capabilities to review an attempt.
     *
     * @param quiz Quiz.
     * @param accessInfo Access info.
     * @param attempt Attempt.
     * @returns Whether user has the capability.
     */
    hasReviewCapabilityForAttempt(
        quiz: AddonModQuizQuizWSData,
        accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
        attempt: AddonModQuizAttemptWSData,
    ): boolean {
        if (accessInfo.canviewreports || accessInfo.canpreview) {
            return true;
        }

        const displayOption = AddonModQuiz.getAttemptStateDisplayOption(quiz, attempt);

        return displayOption === AddonModQuizDisplayOptionsAttemptStates.IMMEDIATELY_AFTER ?
            accessInfo.canattempt : accessInfo.canreviewmyattempts;
    }

    /**
     * Add some calculated data to the attempt.
     *
     * @param quiz Quiz.
     * @param attempt Attempt.
     * @param siteId Site ID.
     * @returns Quiz attempt with calculated data.
     */
    async setAttemptCalculatedData(
        quiz: AddonModQuizQuizData,
        attempt: AddonModQuizAttemptWSData,
        siteId?: string,
    ): Promise<AddonModQuizAttempt> {
        const formattedAttempt = <AddonModQuizAttempt> attempt;

        formattedAttempt.finished = attempt.state === AddonModQuizAttemptStates.FINISHED;
        formattedAttempt.completed = AddonModQuiz.isAttemptCompleted(attempt.state);
        formattedAttempt.rescaledGrade = Number(AddonModQuiz.rescaleGrade(attempt.sumgrades, quiz, false));

        if (quiz.showAttemptsGrades && formattedAttempt.finished) {
            formattedAttempt.formattedGrade = AddonModQuiz.formatGrade(formattedAttempt.rescaledGrade, quiz.decimalpoints);
        } else {
            formattedAttempt.formattedGrade = '';
        }

        formattedAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(attempt.id, siteId);

        return formattedAttempt;
    }

    /**
     * Add some calculated data to the quiz.
     *
     * @param quiz Quiz.
     * @param options Review options.
     * @returns Quiz data with some calculated more.
     */
    setQuizCalculatedData(quiz: AddonModQuizQuizWSData, options: AddonModQuizCombinedReviewOptions): AddonModQuizQuizData {
        const formattedQuiz = <AddonModQuizQuizData> quiz;

        formattedQuiz.sumGradesFormatted = AddonModQuiz.formatGrade(quiz.sumgrades, quiz.decimalpoints);
        formattedQuiz.gradeFormatted = AddonModQuiz.formatGrade(quiz.grade, quiz.decimalpoints);

        formattedQuiz.showAttemptsGrades = options.someoptions.marks >= QuestionDisplayOptionsMarks.MARK_AND_MAX &&
            AddonModQuiz.quizHasGrades(quiz);
        formattedQuiz.showAttemptsMarks = formattedQuiz.showAttemptsGrades && quiz.grade !== quiz.sumgrades;
        formattedQuiz.showFeedback = !!quiz.hasfeedback && !!options.alloptions.overallfeedback;

        return formattedQuiz;
    }

    /**
     * Validate the preflight data. It calls AddonModQuizProvider.startAttempt if a new attempt is needed.
     *
     * @param quiz Quiz.
     * @param accessInfo Quiz access info.
     * @param preflightData Object where to store the preflight data.
     * @param options Options
     * @returns Promise resolved when the preflight data is validated.
     */
    async validatePreflightData(
        quiz: AddonModQuizQuizWSData,
        accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
        preflightData: Record<string, string>,
        options: ValidatePreflightOptions = {},
    ): Promise<AddonModQuizAttempt> {

        const rules = accessInfo.activerulenames;
        const modOptions = {
            cmId: quiz.coursemodule,
            readingStrategy: options.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
            siteId: options.siteId,
        };
        let attempt = options.attempt;

        try {

            if (attempt) {
                if (attempt.state !== AddonModQuizAttemptStates.OVERDUE && !options.finishedOffline) {
                    // We're continuing an attempt. Call getAttemptData to validate the preflight data.
                    await AddonModQuiz.getAttemptData(attempt.id, attempt.currentpage ?? 0, preflightData, modOptions);

                    if (options.offline) {
                        // Get current page stored in local.
                        const storedAttempt = await CoreUtils.ignoreErrors(
                            AddonModQuizOffline.getAttemptById(attempt.id),
                        );

                        attempt.currentpage = storedAttempt?.currentpage ?? attempt.currentpage;
                    }
                } else {
                    // Attempt is overdue or finished in offline, we can only see the summary.
                    // Call getAttemptSummary to validate the preflight data.
                    await AddonModQuiz.getAttemptSummary(attempt.id, preflightData, modOptions);
                }
            } else {
                // We're starting a new attempt, call startAttempt.
                attempt = await AddonModQuiz.startAttempt(quiz.id, preflightData, false, options.siteId);
            }

            // Preflight data validated.
            AddonModQuizAccessRuleDelegate.notifyPreflightCheckPassed(
                rules,
                quiz,
                attempt,
                preflightData,
                options.prefetch,
                options.siteId,
            );

            return attempt;
        } catch (error) {
            if (CoreUtils.isWebServiceError(error)) {
                // The WebService returned an error, assume the preflight failed.
                AddonModQuizAccessRuleDelegate.notifyPreflightCheckFailed(
                    rules,
                    quiz,
                    attempt,
                    preflightData,
                    options.prefetch,
                    options.siteId,
                );
            }

            throw error;
        }
    }

}

export const AddonModQuizHelper = makeSingleton(AddonModQuizHelperProvider);

/**
 * Quiz data with calculated data.
 */
export type AddonModQuizQuizData = AddonModQuizQuizWSData & {
    sumGradesFormatted?: string;
    gradeFormatted?: string;
    showAttemptsGrades?: boolean;
    showAttemptsMarks?: boolean;
    showFeedback?: boolean;
};

/**
 * Attempt data with calculated data.
 */
export type AddonModQuizAttempt = AddonModQuizAttemptWSData & {
    finishedOffline?: boolean;
    rescaledGrade?: number;
    finished?: boolean;
    completed?: boolean;
    formattedGrade?: string;
};

/**
 * Options to validate preflight data.
 */
type ValidatePreflightOptions = {
    attempt?: AddonModQuizAttemptWSData; // Attempt to continue. Don't pass any value if the user needs to start a new attempt.
    offline?: boolean; // Whether the attempt is offline.
    finishedOffline?: boolean; // Whether the attempt is finished offline.
    prefetch?: boolean; // Whether user is prefetching.
    siteId?: string; // Site ID. If not defined, current site.
};

/**
 * Options to check preflight data.
 */
type GetAndCheckPreflightOptions = ValidatePreflightOptions & {
    title?: string; // The title to display in the modal and in the submit button.
    retrying?: boolean; // Whether we're retrying after a failure.
};

/**
 * Options to get preflight data.
 */
type GetPreflightOptions = Omit<GetAndCheckPreflightOptions, 'offline'|'finishedOffline'|'retrying'>;
