// (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 { Subject, BehaviorSubject, Subscription } from 'rxjs';
import { Md5 } from 'ts-md5/dist/md5';

import { CoreFile } from '@services/file';
import { CoreFileHelper } from '@services/file-helper';
import { CoreFilepool } from '@services/filepool';
import { CoreSites } from '@services/sites';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { CoreCourse, CoreCourseAnyModuleData, CoreCourseModuleContentFile } from './course';
import { CoreCache } from '@classes/cache';
import { CoreSiteWSPreSets } from '@classes/site';
import { CoreConstants } from '@/core/constants';
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
import { makeSingleton } from '@singletons';
import { CoreEvents, CoreEventSectionStatusChangedData } from '@singletons/events';
import { CoreError } from '@classes/errors/error';
import { CoreWSFile, CoreWSExternalWarning } from '@services/ws';
import { CHECK_UPDATES_TIMES_TABLE, CoreCourseCheckUpdatesDBRecord } from './database/module-prefetch';
import { CoreFileSizeSum } from '@services/plugin-file-delegate';
import { CoreCourseModuleData } from './course-helper';

const ROOT_CACHE_KEY = 'mmCourse:';

/**
 * Delegate to register module prefetch handlers.
 */
@Injectable({ providedIn: 'root' })
export class CoreCourseModulePrefetchDelegateService extends CoreDelegate<CoreCourseModulePrefetchHandler> {

    protected statusCache = new CoreCache();
    protected featurePrefix = 'CoreCourseModuleDelegate_';
    protected handlerNameProperty = 'modName';

    // Promises for check updates, to prevent performing the same request twice at the same time.
    protected courseUpdatesPromises: Record<string, Record<string, Promise<CourseUpdates>>> = {};

    // Promises and observables for prefetching, to prevent downloading same section twice at the same time and notify progress.
    protected prefetchData: Record<string, Record<string, OngoingPrefetch>> = {};

    constructor() {
        super('CoreCourseModulePrefetchDelegate', true);
    }

    /**
     * Initialize.
     */
    initialize(): void {
        CoreEvents.on(CoreEvents.LOGOUT, this.clearStatusCache.bind(this));

        CoreEvents.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => {
            this.updateStatusCache(data.status, data.component, data.componentId);
        }, CoreSites.getCurrentSiteId());

        // If a file inside a module is downloaded/deleted, clear the corresponding cache.
        CoreEvents.on(CoreEvents.COMPONENT_FILE_ACTION, (data) => {
            if (!CoreFilepool.isFileEventDownloadedOrDeleted(data)) {
                return;
            }

            this.statusCache.invalidate(CoreFilepool.getPackageId(data.component, data.componentId));
        }, CoreSites.getCurrentSiteId());
    }

    /**
     * Check if current site can check updates using core_course_check_updates.
     *
     * @return True if can check updates, false otherwise.
     * @deprecated since app 4.0
     */
    canCheckUpdates(): boolean {
        return true;
    }

    /**
     * Check if a certain module can use core_course_check_updates.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved with boolean: whether the module can use check updates WS.
     */
    async canModuleUseCheckUpdates(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
        const handler = this.getPrefetchHandlerFor(module.modname);

        if (!handler) {
            // Module not supported, cannot use check updates.
            return false;
        }

        if (handler.canUseCheckUpdates) {
            return await handler.canUseCheckUpdates(module, courseId);
        }

        // By default, modules can use check updates.
        return true;
    }

    /**
     * Clear the status cache.
     */
    clearStatusCache(): void {
        this.statusCache.clear();
    }

    /**
     * Creates the list of modules to check for get course updates.
     *
     * @param modules List of modules.
     * @param courseId Course ID the modules belong to.
     * @return Promise resolved with the lists.
     */
    protected async createToCheckList(modules: CoreCourseModuleData[], courseId: number): Promise<ToCheckList> {
        const result: ToCheckList = {
            toCheck: [],
            cannotUse: [],
        };

        const promises = modules.map(async (module) => {
            try {
                const data = await this.getModuleStatusAndDownloadTime(module, courseId);
                if (data.status != CoreConstants.DOWNLOADED) {
                    return;
                }

                // Module is downloaded and not outdated. Check if it can check updates.
                const canUse = await this.canModuleUseCheckUpdates(module, courseId);
                if (canUse) {
                    // Can use check updates, add it to the tocheck list.
                    result.toCheck.push({
                        contextlevel: 'module',
                        id: module.id,
                        since: data.downloadTime || 0,
                    });
                } else {
                    // Cannot use check updates, add it to the cannotUse array.
                    result.cannotUse.push(module);
                }
            } catch {
                // Ignore errors.
            }
        });

        await Promise.all(promises);

        // Sort toCheck list.
        result.toCheck.sort((a, b) => a.id >= b.id ? 1 : -1);

        return result;
    }

    /**
     * Determines a module status based on current status, restoring downloads if needed.
     *
     * @param module Module.
     * @param status Current status.
     * @return Module status.
     */
    determineModuleStatus(module: CoreCourseAnyModuleData, status: string): string {
        const handler = this.getPrefetchHandlerFor(module.modname);
        const siteId = CoreSites.getCurrentSiteId();

        if (!handler) {
            return status;
        }

        if (status == CoreConstants.DOWNLOADING) {
            // Check if the download is being handled.
            if (!CoreFilepool.getPackageDownloadPromise(siteId, handler.component, module.id)) {
                // Not handled, the app was probably restarted or something weird happened.
                // Re-start download (files already on queue or already downloaded will be skipped).
                handler.prefetch(module, module.course);
            }
        } else if (handler.determineStatus) {
            // The handler implements a determineStatus function. Apply it.
            return handler.determineStatus(module, status, true);
        }

        return status;
    }

    /**
     * Download a module.
     *
     * @param module Module to download.
     * @param courseId Course ID the module belongs to.
     * @param dirPath Path of the directory where to store all the content files.
     * @return Promise resolved when finished.
     */
    async downloadModule(module: CoreCourseAnyModuleData, courseId: number, dirPath?: string): Promise<void> {
        // Check if the module has a prefetch handler.
        const handler = this.getPrefetchHandlerFor(module.modname);

        if (!handler) {
            return;
        }

        await this.syncModule(module, courseId);

        await handler.download(module, courseId, dirPath);
    }

    /**
     * Check for updates in a course.
     *
     * @param modules List of modules.
     * @param courseId Course ID the modules belong to.
     * @return Promise resolved with the updates. If a module is set to false, it means updates cannot be
     *         checked for that module in the current site.
     */
    async getCourseUpdates(modules: CoreCourseModuleData[], courseId: number): Promise<CourseUpdates> {
        // Check if there's already a getCourseUpdates in progress.
        const id = <string> Md5.hashAsciiStr(courseId + '#' + JSON.stringify(modules));
        const siteId = CoreSites.getCurrentSiteId();

        if (this.courseUpdatesPromises[siteId] && this.courseUpdatesPromises[siteId][id] !== undefined) {
            // There's already a get updates ongoing, return the promise.
            return this.courseUpdatesPromises[siteId][id];
        } else if (!this.courseUpdatesPromises[siteId]) {
            this.courseUpdatesPromises[siteId] = {};
        }

        this.courseUpdatesPromises[siteId][id] = this.fetchCourseUpdates(modules, courseId, siteId);

        try {
            return await this.courseUpdatesPromises[siteId][id];
        } finally {
            // Get updates finished, delete the promise.
            delete this.courseUpdatesPromises[siteId][id];
        }
    }

    /**
     * Fetch updates in a course.
     *
     * @param modules List of modules.
     * @param courseId Course ID the modules belong to.
     * @param siteId Site ID.
     * @return Promise resolved with the updates. If a module is set to false, it means updates cannot be
     *         checked for that module in the site.
     */
    protected async fetchCourseUpdates(
        modules: CoreCourseModuleData[],
        courseId: number,
        siteId: string,
    ): Promise<CourseUpdates> {
        const data = await this.createToCheckList(modules, courseId);
        const result: CourseUpdates = {};

        // Mark as false the modules that cannot use check updates WS.
        data.cannotUse.forEach((module) => {
            result[module.id] = false;
        });

        if (!data.toCheck.length) {
            // Nothing to check, no need to call the WS.
            return result;
        }

        // Get the site, maybe the user changed site.
        const site = await CoreSites.getSite(siteId);

        const params: CoreCourseCheckUpdatesWSParams = {
            courseid: courseId,
            tocheck: data.toCheck,
        };
        const preSets: CoreSiteWSPreSets = {
            cacheKey: this.getCourseUpdatesCacheKey(courseId),
            emergencyCache: false, // If downloaded data has changed and offline, just fail. See MOBILE-2085.
            uniqueCacheKey: true,
            splitRequest: {
                param: 'tocheck',
                maxLength: 10,
            },
        };

        try {
            const response = await site.read<CoreCourseCheckUpdatesWSResponse>('core_course_check_updates', params, preSets);

            // Store the last execution of the check updates call.
            const entry: CoreCourseCheckUpdatesDBRecord = {
                courseId: courseId,
                time: CoreTimeUtils.timestamp(),
            };
            CoreUtils.ignoreErrors(site.getDb().insertRecord(CHECK_UPDATES_TIMES_TABLE, entry));

            return this.treatCheckUpdatesResult(data.toCheck, response, result);
        } catch (error) {
            // Cannot get updates.
            // Get cached entries but discard modules with a download time higher than the last execution of check updates.
            let entry: CoreCourseCheckUpdatesDBRecord | undefined;
            try {
                entry = await site.getDb().getRecord<CoreCourseCheckUpdatesDBRecord>(
                    CHECK_UPDATES_TIMES_TABLE,
                    { courseId: courseId },
                );
            } catch {
                // No previous executions, return result as it is.
                return result;
            }

            preSets.getCacheUsingCacheKey = true;
            preSets.omitExpires = true;

            const response = await site.read<CoreCourseCheckUpdatesWSResponse>('core_course_check_updates', params, preSets);

            return this.treatCheckUpdatesResult(data.toCheck, response, result, entry.time);
        }
    }

    /**
     * Check for updates in a course.
     *
     * @param courseId Course ID the modules belong to.
     * @return Promise resolved with the updates.
     */
    async getCourseUpdatesByCourseId(courseId: number): Promise<CourseUpdates> {
        // Get course sections and all their modules.
        const sections = await CoreCourse.getSections(courseId, false, true, { omitExpires: true });

        return this.getCourseUpdates(CoreCourse.getSectionsModules(sections), courseId);
    }

    /**
     * Get cache key for course updates WS calls.
     *
     * @param courseId Course ID.
     * @return Cache key.
     */
    protected getCourseUpdatesCacheKey(courseId: number): string {
        return ROOT_CACHE_KEY + 'courseUpdates:' + courseId;
    }

    /**
     * Get modules download size. Only treat the modules with status not downloaded or outdated.
     *
     * @param modules List of modules.
     * @param courseId Course ID the modules belong to.
     * @return Promise resolved with the size.
     */
    async getDownloadSize(modules: CoreCourseModuleData[], courseId: number): Promise<CoreFileSizeSum> {
        // Get the status of each module.
        const data = await this.getModulesStatus(modules, courseId);

        const downloadableModules = data[CoreConstants.NOT_DOWNLOADED].concat(data[CoreConstants.OUTDATED]);
        const result: CoreFileSizeSum = {
            size: 0,
            total: true,
        };

        await Promise.all(downloadableModules.map(async (module) => {
            const size = await this.getModuleDownloadSize(module, courseId);

            result.total = result.total && size.total;
            result.size += size.size;
        }));

        return result;
    }

    /**
     * Get the download size of a module.
     *
     * @param module Module to get size.
     * @param courseId Course ID the module belongs to.
     * @param single True if we're downloading a single module, false if we're downloading a whole section.
     * @return Promise resolved with the size.
     */
    async getModuleDownloadSize(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise<CoreFileSizeSum> {
        const handler = this.getPrefetchHandlerFor(module.modname);

        if (!handler) {
            return { size: 0, total: false };
        }
        const downloadable = await this.isModuleDownloadable(module, courseId);
        if (!downloadable) {
            return { size: 0, total: true };
        }

        const packageId = CoreFilepool.getPackageId(handler.component, module.id);
        const downloadSize = this.statusCache.getValue<CoreFileSizeSum>(packageId, 'downloadSize');
        if (downloadSize !== undefined) {
            return downloadSize;
        }

        try {
            const size = await handler.getDownloadSize(module, courseId, single);

            return this.statusCache.setValue(packageId, 'downloadSize', size);
        } catch (error) {
            const cachedSize = this.statusCache.getValue<CoreFileSizeSum>(packageId, 'downloadSize', true);
            if (cachedSize) {
                return cachedSize;
            }

            throw error;
        }
    }

    /**
     * Get the download size of a module.
     *
     * @param module Module to get size.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved with the size.
     */
    async getModuleDownloadedSize(module: CoreCourseAnyModuleData, courseId: number): Promise<number> {
        const handler = this.getPrefetchHandlerFor(module.modname);
        if (!handler) {
            return 0;
        }

        const downloadable = await this.isModuleDownloadable(module, courseId);
        if (!downloadable) {
            return 0;
        }

        const packageId = CoreFilepool.getPackageId(handler.component, module.id);
        const downloadedSize = this.statusCache.getValue<number>(packageId, 'downloadedSize');
        if (downloadedSize !== undefined) {
            return downloadedSize;
        }

        try {
            let size = 0;

            if (handler.getDownloadedSize) {
                // Handler implements a method to calculate the downloaded size, use it.
                size = await handler.getDownloadedSize(module, courseId);
            } else {
                // Handler doesn't implement it, get the module files and check if they're downloaded.
                const files = await this.getModuleFiles(module, courseId);

                const siteId = CoreSites.getCurrentSiteId();

                // Retrieve file size if it's downloaded.
                await Promise.all(files.map(async (file) => {
                    const path = await CoreFilepool.getFilePathByUrl(siteId, CoreFileHelper.getFileUrl(file));

                    try {
                        const fileSize = await CoreFile.getFileSize(path);

                        size += fileSize;
                    } catch {
                        // Error getting size. Check if the file is being downloaded.
                        const isDownloading = await CoreFilepool.isFileDownloadingByUrl(siteId, CoreFileHelper.getFileUrl(file));
                        if (isDownloading) {
                            // If downloading, count as downloaded.
                            size += file.filesize || 0;
                        }
                    }
                }));
            }

            return this.statusCache.setValue(packageId, 'downloadedSize', size);
        } catch {
            return this.statusCache.getValue<number>(packageId, 'downloadedSize', true) || 0;
        }
    }

    /**
     * Gets the estimated total size of data stored for a module. This includes
     * the files downloaded for it (getModuleDownloadedSize) and also the total
     * size of web service requests stored for it.
     *
     * @param module Module to get the size.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved with the total size (0 if unknown)
     */
    async getModuleStoredSize(module: CoreCourseAnyModuleData, courseId: number): Promise<number> {
        let downloadedSize = await this.getModuleDownloadedSize(module, courseId);

        if (isNaN(downloadedSize)) {
            downloadedSize = 0;
        }

        const site = CoreSites.getCurrentSite();
        const handler = this.getPrefetchHandlerFor(module.modname);
        if (!handler || !site) {
            // If there is no handler then we can't find out the component name.
            // We can't work out the cached size, so just return downloaded size.
            return downloadedSize;
        }

        const cachedSize = await site.getComponentCacheSize(handler.component, module.id);

        return cachedSize + downloadedSize;
    }

    /**
     * Get module files.
     *
     * @param module Module to get the files.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved with the list of files.
     */
    async getModuleFiles(
        module: CoreCourseAnyModuleData,
        courseId: number,
    ): Promise<(CoreWSFile | CoreCourseModuleContentFile)[]> {
        const handler = this.getPrefetchHandlerFor(module.modname);

        if (handler?.getFiles) {
            // The handler defines a function to get files, use it.
            return await handler.getFiles(module, courseId);
        } else if (handler?.loadContents) {
            // The handler defines a function to load contents, use it before returning module contents.
            await handler.loadContents(module, courseId);

            return module.contents || [];
        } else {
            return module.contents || [];
        }
    }

    /**
     * Get the module status.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @param updates Result of getCourseUpdates for all modules in the course. If not provided, it will be
     *                calculated (slower). If it's false it means the site doesn't support check updates.
     * @param refresh True if it should ignore the memory cache, not the WS cache.
     * @param sectionId ID of the section the module belongs to.
     * @return Promise resolved with the status.
     */
    async getModuleStatus(
        module: CoreCourseAnyModuleData,
        courseId: number,
        updates?: CourseUpdates | false,
        refresh?: boolean,
        sectionId?: number,
    ): Promise<string> {
        const handler = this.getPrefetchHandlerFor(module.modname);

        if (!handler) {
            // No handler found, module not downloadable.
            return CoreConstants.NOT_DOWNLOADABLE;
        }

        // Check if the status is cached.
        const packageId = CoreFilepool.getPackageId(handler.component, module.id);
        const status = this.statusCache.getValue<string>(packageId, 'status');

        if (!refresh && status !== undefined) {
            this.storeCourseAndSection(packageId, courseId, sectionId);

            return this.determineModuleStatus(module, status);
        }

        const result = await this.calculateModuleStatus(handler, module, courseId, updates, sectionId);
        if (result.updateStatus) {
            this.updateStatusCache(result.status, handler.component, module.id, courseId, sectionId);
        }

        return this.determineModuleStatus(module, result.status);
    }

    /**
     * Calculate a module status.
     *
     * @param handler Prefetch handler.
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @param updates Result of getCourseUpdates for all modules in the course. If not provided, it will be
     *                calculated (slower). If it's false it means the site doesn't support check updates.
     * @param sectionId ID of the section the module belongs to.
     * @return Promise resolved with the status.
     */
    protected async calculateModuleStatus(
        handler: CoreCourseModulePrefetchHandler,
        module: CoreCourseAnyModuleData,
        courseId: number,
        updates?: CourseUpdates | false,
        sectionId?: number,
    ): Promise<{status: string; updateStatus: boolean}> {
        // Check if the module is downloadable.
        const downloadable = await this.isModuleDownloadable(module, courseId);
        if (!downloadable) {
            return {
                status: CoreConstants.NOT_DOWNLOADABLE,
                updateStatus: true,
            };
        }

        // Get the saved package status.
        const siteId = CoreSites.getCurrentSiteId();
        const currentStatus = await CoreFilepool.getPackageStatus(siteId, handler.component, module.id);

        let status = handler.determineStatus ? handler.determineStatus(module, currentStatus, true) : currentStatus;
        if (status != CoreConstants.DOWNLOADED || updates === false) {
            return {
                status,
                updateStatus: true,
            };
        }

        // Module is downloaded. Determine if there are updated in the module to show them outdated.
        if (updates === undefined) {
            try {
                // We don't have course updates, calculate them.
                updates = await this.getCourseUpdatesByCourseId(courseId);
            } catch {
                // Error getting updates, show the stored status.
                const packageId = CoreFilepool.getPackageId(handler.component, module.id);
                this.storeCourseAndSection(packageId, courseId, sectionId);

                return {
                    status: currentStatus,
                    updateStatus: false,
                };
            }
        }

        if (!updates || updates[module.id] === false) {
            // Cannot check updates, always show outdated.
            return {
                status: CoreConstants.OUTDATED,
                updateStatus: true,
            };
        }

        try {
            // Check if the module has any update.
            const hasUpdates = await this.moduleHasUpdates(module, courseId, updates);

            if (!hasUpdates) {
                // No updates, keep current status.
                return {
                    status,
                    updateStatus: true,
                };
            }

            // Has updates, mark the module as outdated.
            status = CoreConstants.OUTDATED;

            await CoreUtils.ignoreErrors(
                CoreFilepool.storePackageStatus(siteId, status, handler.component, module.id),
            );

            return {
                status,
                updateStatus: true,
            };
        } catch {
            // Error checking if module has updates.
            const packageId = CoreFilepool.getPackageId(handler.component, module.id);
            const status = this.statusCache.getValue<string>(packageId, 'status', true);

            return {
                status: this.determineModuleStatus(module, status || CoreConstants.NOT_DOWNLOADED),
                updateStatus: true,
            };
        }
    }

    /**
     * Get the status of a list of modules, along with the lists of modules for each status.
     *
     * @param modules List of modules to prefetch.
     * @param courseId Course ID the modules belong to.
     * @param sectionId ID of the section the modules belong to.
     * @param refresh True if it should always check the DB (slower).
     * @param onlyToDisplay True if the status will only be used to determine which button should be displayed.
     * @param checkUpdates Whether to use the WS to check updates. Defaults to true.
     * @return Promise resolved with the data.
     */
    async getModulesStatus(
        modules: CoreCourseModuleData[],
        courseId: number,
        sectionId?: number,
        refresh?: boolean,
        onlyToDisplay?: boolean,
        checkUpdates: boolean = true,
    ): Promise<CoreCourseModulesStatus> {

        let updates: CourseUpdates | false = false;
        const result: CoreCourseModulesStatus = {
            total: 0,
            status: CoreConstants.NOT_DOWNLOADABLE,
            [CoreConstants.NOT_DOWNLOADED]: [],
            [CoreConstants.DOWNLOADED]: [],
            [CoreConstants.DOWNLOADING]: [],
            [CoreConstants.OUTDATED]: [],
        };

        if (checkUpdates) {
            // Check updates in course. Don't use getCourseUpdates because the list of modules might not be the whole course list.
            try {
                updates = await this.getCourseUpdatesByCourseId(courseId);
            } catch {
                // Cannot get updates.
            }
        }

        await Promise.all(modules.map(async (module) => {
            const handler = this.getPrefetchHandlerFor(module.modname);
            if (!handler || (onlyToDisplay && handler.skipListStatus)) {
                return;
            }

            try {
                const modStatus = await this.getModuleStatus(module, courseId, updates, refresh);

                if (!result[modStatus]) {
                    return;
                }

                result.status = CoreFilepool.determinePackagesStatus(result.status, modStatus);
                result[modStatus].push(module);
                result.total++;
            } catch (error) {
                const packageId = CoreFilepool.getPackageId(handler.component, module.id);
                const cacheStatus = this.statusCache.getValue<string>(packageId, 'status', true);
                if (cacheStatus === undefined) {
                    throw error;
                }

                if (!result[cacheStatus]) {
                    return;
                }

                result.status = CoreFilepool.determinePackagesStatus(result.status, cacheStatus);
                result[cacheStatus].push(module);
                result.total++;
            }
        }));

        return result;
    }

    /**
     * Get a module status and download time. It will only return the download time if the module is downloaded or outdated.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved with the data.
     */
    protected async getModuleStatusAndDownloadTime(
        module: CoreCourseAnyModuleData,
        courseId: number,
    ): Promise<{ status: string; downloadTime?: number }> {
        const handler = this.getPrefetchHandlerFor(module.modname);
        const siteId = CoreSites.getCurrentSiteId();

        if (!handler) {
            // No handler found, module not downloadable.
            return { status: CoreConstants.NOT_DOWNLOADABLE };
        }

        // Get the status from the cache.
        const packageId = CoreFilepool.getPackageId(handler.component, module.id);
        const status = this.statusCache.getValue<string>(packageId, 'status');

        if (status !== undefined && !CoreFileHelper.isStateDownloaded(status)) {
            // Module isn't downloaded, just return the status.
            return { status };
        }

        // Check if the module is downloadable.
        const downloadable = await this.isModuleDownloadable(module, courseId);
        if (!downloadable) {
            return { status: CoreConstants.NOT_DOWNLOADABLE };
        }

        // Get the stored data to get the status and downloadTime.
        const data = await CoreFilepool.getPackageData(siteId, handler.component, module.id);

        return {
            status: data.status || CoreConstants.NOT_DOWNLOADED,
            downloadTime: data.downloadTime || 0,
        };
    }

    /**
     * Get updates for a certain module.
     * It will only return the updates if the module can use check updates and it's downloaded or outdated.
     *
     * @param module Module to check.
     * @param courseId Course the module belongs to.
     * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
     * @param siteId Site ID. If not defined, current site.
     * @return Promise resolved with the updates.
     */
    async getModuleUpdates(
        module: CoreCourseAnyModuleData,
        courseId: number,
        ignoreCache?: boolean,
        siteId?: string,
    ): Promise<CheckUpdatesWSInstance | null> {

        const site = await CoreSites.getSite(siteId);

        const data = await this.getModuleStatusAndDownloadTime(module, courseId);
        if (!CoreFileHelper.isStateDownloaded(data.status)) {
            // Not downloaded, no updates.
            return null;
        }

        // Module is downloaded. Check if it can check updates.
        const canUse = await this.canModuleUseCheckUpdates(module, courseId);
        if (!canUse) {
            // Can't use check updates, no updates.
            return null;
        }

        const params: CoreCourseCheckUpdatesWSParams = {
            courseid: courseId,
            tocheck: [
                {
                    contextlevel: 'module',
                    id: module.id,
                    since: data.downloadTime || 0,
                },
            ],
        };
        const preSets: CoreSiteWSPreSets = {
            cacheKey: this.getModuleUpdatesCacheKey(courseId, module.id),
        };

        if (ignoreCache) {
            preSets.getFromCache = false;
            preSets.emergencyCache = false;
        }

        const response = await site.read<CoreCourseCheckUpdatesWSResponse>('core_course_check_updates', params, preSets);
        if (!response.instances[0]) {
            throw new CoreError('Could not get module updates.');
        }

        return response.instances[0];
    }

    /**
     * Get cache key for module updates WS calls.
     *
     * @param courseId Course ID.
     * @param moduleId Module ID.
     * @return Cache key.
     */
    protected getModuleUpdatesCacheKey(courseId: number, moduleId: number): string {
        return this.getCourseUpdatesCacheKey(courseId) + ':' + moduleId;
    }

    /**
     * Get a prefetch handler.
     *
     * @param moduleName The module name to work on.
     * @return Prefetch handler.
     */
    getPrefetchHandlerFor(moduleName: string): CoreCourseModulePrefetchHandler | undefined {
        return this.getHandler(moduleName, true);
    }

    /**
     * Invalidate check updates WS call.
     *
     * @param courseId Course ID.
     * @return Promise resolved when data is invalidated.
     */
    async invalidateCourseUpdates(courseId: number): Promise<void> {
        const site = CoreSites.getCurrentSite();
        if (!site) {
            return;
        }

        await site.invalidateWsCacheForKey(this.getCourseUpdatesCacheKey(courseId));
    }

    /**
     * Invalidate a list of modules in a course. This should only invalidate WS calls, not downloaded files.
     *
     * @param modules List of modules.
     * @param courseId Course ID.
     * @return Promise resolved when modules are invalidated.
     */
    async invalidateModules(modules: CoreCourseModuleData[], courseId: number): Promise<void> {

        const promises = modules.map(async (module) => {
            const handler = this.getPrefetchHandlerFor(module.modname);
            if (!handler) {
                return;
            }

            if (handler.invalidateModule) {
                await CoreUtils.ignoreErrors(handler.invalidateModule(module, courseId));
            }

            // Invalidate cache.
            this.invalidateModuleStatusCache(module);
        });

        promises.push(this.invalidateCourseUpdates(courseId));

        await Promise.all(promises);
    }

    /**
     * Invalidates the cache for a given module.
     *
     * @param module Module to be invalidated.
     */
    invalidateModuleStatusCache(module: CoreCourseAnyModuleData): void {
        const handler = this.getPrefetchHandlerFor(module.modname);
        if (handler) {
            this.statusCache.invalidate(CoreFilepool.getPackageId(handler.component, module.id));
        }
    }

    /**
     * Invalidate check updates WS call for a certain module.
     *
     * @param courseId Course ID.
     * @param moduleId Module ID.
     * @param siteId Site ID. If not defined, current site.
     * @return Promise resolved when data is invalidated.
     */
    async invalidateModuleUpdates(courseId: number, moduleId: number, siteId?: string): Promise<void> {
        const site = await CoreSites.getSite(siteId);

        await site.invalidateWsCacheForKey(this.getModuleUpdatesCacheKey(courseId, moduleId));
    }

    /**
     * Check if a list of modules is being downloaded.
     *
     * @param id An ID to identify the download.
     * @return True if it's being downloaded, false otherwise.
     */
    isBeingDownloaded(id: string): boolean {
        const siteId = CoreSites.getCurrentSiteId();

        return !!(this.prefetchData[siteId]?.[id]);
    }

    /**
     * Check if a module is downloadable.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved with true if downloadable, false otherwise.
     */
    async isModuleDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
        if ('uservisible' in module && module.uservisible === false) {
            // Module isn't visible by the user, cannot be downloaded.
            return false;
        }

        const handler = this.getPrefetchHandlerFor(module.modname);
        if (!handler) {
            return false;
        }

        if (!handler.isDownloadable) {
            // Function not defined, assume it's downloadable.
            return true;
        }

        const packageId = CoreFilepool.getPackageId(handler.component, module.id);
        let downloadable = this.statusCache.getValue<boolean>(packageId, 'downloadable');

        if (downloadable !== undefined) {
            return downloadable;
        }

        try {
            downloadable = await handler.isDownloadable(module, courseId);

            return this.statusCache.setValue(packageId, 'downloadable', downloadable);
        } catch {
            // Something went wrong, assume it's not downloadable.
            return false;
        }
    }

    /**
     * Check if a module has updates based on the result of getCourseUpdates.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @param updates Result of getCourseUpdates.
     * @return Promise resolved with boolean: whether the module has updates.
     */
    async moduleHasUpdates(module: CoreCourseAnyModuleData, courseId: number, updates: CourseUpdates): Promise<boolean> {
        const handler = this.getPrefetchHandlerFor(module.modname);
        const moduleUpdates = updates[module.id];

        if (handler?.hasUpdates) {
            // Handler implements its own function to check the updates, use it.
            return await handler.hasUpdates(module, courseId, moduleUpdates);
        } else if (!moduleUpdates || !moduleUpdates.updates || !moduleUpdates.updates.length) {
            // Module doesn't have any update.
            return false;
        } else if (handler?.updatesNames?.test) {
            // Check the update names defined by the handler.
            for (let i = 0, len = moduleUpdates.updates.length; i < len; i++) {
                if (handler.updatesNames.test(moduleUpdates.updates[i].name)) {
                    return true;
                }
            }

            return false;
        }

        // Handler doesn't define hasUpdates or updatesNames and there is at least 1 update. Assume it has updates.
        return true;
    }

    /**
     * Prefetch a module.
     *
     * @param module Module to prefetch.
     * @param courseId Course ID the module belongs to.
     * @param single True if we're downloading a single module, false if we're downloading a whole section.
     * @return Promise resolved when finished.
     */
    async prefetchModule(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise<void> {
        const handler = this.getPrefetchHandlerFor(module.modname);
        if (!handler) {
            return;
        }

        await this.syncModule(module, courseId);

        await handler.prefetch(module, courseId, single);
    }

    /**
     * Sync a group of modules.
     *
     * @param modules Array of modules to sync.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved when finished.
     */
    syncModules(modules: CoreCourseModuleData[], courseId: number): Promise<unknown> {
        return Promise.all(modules.map(async (module) => {
            await this.syncModule(module, courseId);

            // Invalidate course updates.
            await CoreUtils.ignoreErrors(this.invalidateCourseUpdates(courseId));
        }));
    }

    /**
     * Sync a module.
     *
     * @param module Module to sync.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved when finished.
     */
    async syncModule<T = unknown>(module: CoreCourseAnyModuleData, courseId: number): Promise<T | undefined> {
        const handler = this.getPrefetchHandlerFor(module.modname);
        if (!handler?.sync) {
            return;
        }

        const result = await CoreUtils.ignoreErrors(handler.sync(module, courseId));

        // Always invalidate status cache for this module. We cannot know if data was sent to server or not.
        this.invalidateModuleStatusCache(module);

        return <T> result;
    }

    /**
     * Prefetches a list of modules using their prefetch handlers.
     * If a prefetch already exists for this site and id, returns the current promise.
     *
     * @param id An ID to identify the download. It can be used to retrieve the download promise.
     * @param modules List of modules to prefetch.
     * @param courseId Course ID the modules belong to.
     * @param onProgress Function to call everytime a module is downloaded.
     * @return Promise resolved when all modules have been prefetched.
     */
    async prefetchModules(
        id: string,
        modules: CoreCourseModuleData[],
        courseId: number,
        onProgress?: CoreCourseModulesProgressFunction,
    ): Promise<void> {

        const siteId = CoreSites.getCurrentSiteId();
        const currentPrefetchData = this.prefetchData[siteId]?.[id];

        if (currentPrefetchData) {
            // There's a prefetch ongoing, return the current promise.
            if (onProgress) {
                currentPrefetchData.subscriptions.push(currentPrefetchData.observable.subscribe(onProgress));
            }

            return currentPrefetchData.promise;
        }

        let count = 0;
        const total = modules.length;
        const moduleIds = modules.map((module) => module.id);
        const prefetchData: OngoingPrefetch = {
            observable: new BehaviorSubject<CoreCourseModulesProgress>({ count: count, total: total }),
            promise: Promise.resolve(),
            subscriptions: [],
        };

        if (onProgress) {
            prefetchData.observable.subscribe(onProgress);
        }

        const promises = modules.map(async (module) => {
            // Check if the module has a prefetch handler.
            const handler = this.getPrefetchHandlerFor(module.modname);
            if (!handler) {
                return;
            }

            const downloadable = await this.isModuleDownloadable(module, courseId);
            if (!downloadable) {
                return;
            }

            await handler.prefetch(module, courseId);

            const index = moduleIds.indexOf(module.id);
            if (index > -1) {
                moduleIds.splice(index, 1);
                count++;
                prefetchData.observable.next({ count: count, total: total });
            }
        });

        // Set the promise.
        prefetchData.promise = CoreUtils.allPromises(promises);

        // Store the prefetch data in the list.
        this.prefetchData[siteId] = this.prefetchData[siteId] || {};
        this.prefetchData[siteId][id] = prefetchData;

        try {
            await prefetchData.promise;
        } finally {
            // Unsubscribe all observers.
            prefetchData.subscriptions.forEach((subscription) => {
                subscription.unsubscribe();
            });
            delete this.prefetchData[siteId][id];
        }
    }

    /**
     * Remove module Files from handler.
     *
     * @param module Module to remove the files.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved when done.
     */
    async removeModuleFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
        const handler = this.getPrefetchHandlerFor(module.modname);
        const siteId = CoreSites.getCurrentSiteId();

        if (handler?.removeFiles) {
            // Handler implements a method to remove the files, use it.
            await handler.removeFiles(module, courseId);
        } else {
            // No method to remove files, use get files to try to remove the files.
            const files = await this.getModuleFiles(module, courseId);

            await Promise.all(files.map(async (file) => {
                await CoreUtils.ignoreErrors(CoreFilepool.removeFileByUrl(siteId, CoreFileHelper.getFileUrl(file)));
            }));
        }

        if (!handler) {
            return;
        }

        // Update downloaded size.
        const packageId = CoreFilepool.getPackageId(handler.component, module.id);
        this.statusCache.setValue(packageId, 'downloadedSize', 0);

        // If module is downloadable, set not dowloaded status.
        const downloadable = await this.isModuleDownloadable(module, courseId);
        if (!downloadable) {
            return;
        }

        await CoreFilepool.storePackageStatus(siteId, CoreConstants.NOT_DOWNLOADED, handler.component, module.id);
    }

    /**
     * Set an on progress function for the download of a list of modules.
     *
     * @param id An ID to identify the download.
     * @param onProgress Function to call everytime a module is downloaded.
     */
    setOnProgress(id: string, onProgress: CoreCourseModulesProgressFunction): void {
        const currentData = this.prefetchData[CoreSites.getCurrentSiteId()]?.[id];

        if (currentData) {
            // There's a prefetch ongoing, return the current promise.
            currentData.subscriptions.push(currentData.observable.subscribe(onProgress));
        }
    }

    /**
     * If courseId or sectionId is set, save them in the cache.
     *
     * @param packageId The package ID.
     * @param courseId Course ID.
     * @param sectionId Section ID.
     */
    storeCourseAndSection(packageId: string, courseId?: number, sectionId?: number): void {
        if (courseId) {
            this.statusCache.setValue(packageId, 'courseId', courseId);
        }
        if (sectionId && sectionId > 0) {
            this.statusCache.setValue(packageId, 'sectionId', sectionId);
        }
    }

    /**
     * Treat the result of the check updates WS call.
     *
     * @param toCheckList List of modules to check (from createToCheckList).
     * @param response WS call response.
     * @param result Object where to store the result.
     * @param previousTime Time of the previous check updates execution. If set, modules downloaded
     *                     after this time will be ignored.
     * @return Result.
     */
    protected treatCheckUpdatesResult(
        toCheckList: CheckUpdatesToCheckWSParam[],
        response: CoreCourseCheckUpdatesWSResponse,
        result: CourseUpdates,
        previousTime?: number,
    ): CourseUpdates {
        // Format the response to index it by module ID.
        CoreUtils.arrayToObject<false | CheckUpdatesWSInstance>(response.instances, 'id', result);

        // Treat warnings, adding the not supported modules.
        response.warnings?.forEach((warning) => {
            if (warning.warningcode == 'missingcallback') {
                result[warning.itemid || -1] = false;
            }
        });

        if (previousTime) {
            // Remove from the list the modules downloaded after previousTime.
            toCheckList.forEach((entry) => {
                if (result[entry.id] && entry.since > previousTime) {
                    delete result[entry.id];
                }
            });
        }

        return result;
    }

    /**
     * Update the status of a module in the "cache".
     *
     * @param status New status.
     * @param component Package's component.
     * @param componentId An ID to use in conjunction with the component.
     * @param courseId Course ID of the module.
     * @param sectionId Section ID of the module.
     */
    updateStatusCache(
        status: string,
        component: string,
        componentId?: string | number,
        courseId?: number,
        sectionId?: number,
    ): void {
        const packageId = CoreFilepool.getPackageId(component, componentId);
        const cachedStatus = this.statusCache.getValue<string>(packageId, 'status', true);

        // If courseId/sectionId is set, store it.
        this.storeCourseAndSection(packageId, courseId, sectionId);

        if (cachedStatus === undefined || cachedStatus === status) {
            this.statusCache.setValue(packageId, 'status', status);

            return;
        }

        // The status has changed, notify that the section has changed.
        courseId = courseId || this.statusCache.getValue(packageId, 'courseId', true);
        sectionId = sectionId || this.statusCache.getValue(packageId, 'sectionId', true);

        // Invalidate and set again.
        this.statusCache.invalidate(packageId);
        this.statusCache.setValue(packageId, 'status', status);

        if (courseId && sectionId) {
            const data: CoreEventSectionStatusChangedData = {
                sectionId,
                courseId: courseId,
            };
            CoreEvents.trigger(CoreEvents.SECTION_STATUS_CHANGED, data, CoreSites.getCurrentSiteId());
        }
    }

}

export const CoreCourseModulePrefetchDelegate = makeSingleton(CoreCourseModulePrefetchDelegateService);

/**
 * Progress of downloading a list of modules.
 */
export type CoreCourseModulesProgress = {
    /**
     * Number of modules downloaded so far.
     */
    count: number;

    /**
     * Toal of modules to download.
     */
    total: number;
};

/**
 * Progress function for downloading a list of modules.
 *
 * @param data Progress data.
 */
export type CoreCourseModulesProgressFunction = (data: CoreCourseModulesProgress) => void;

/**
 * Interface that all course prefetch handlers must implement.
 */
export interface CoreCourseModulePrefetchHandler extends CoreDelegateHandler {
    /**
     * Name of the handler.
     */
    name: string;

    /**
     * Name of the module. It should match the "modname" of the module returned in core_course_get_contents.
     */
    modName: string;

    /**
     * The handler's component.
     */
    component: string;

    /**
     * The RegExp to check updates. If a module has an update whose name matches this RegExp, the module will be marked
     * as outdated. This RegExp is ignored if hasUpdates function is defined.
     */
    updatesNames?: RegExp;

    /**
     * If true, this module will be treated as not downloadable when determining the status of a list of modules. The module will
     * still be downloaded when downloading the section/course, it only affects whether the button should be displayed.
     */
    skipListStatus: boolean;

    /**
     * Get the download size of a module.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @param single True if we're downloading a single module, false if we're downloading a whole section.
     * @return Promise resolved with the size.
     */
    getDownloadSize(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise<CoreFileSizeSum>;

    /**
     * Prefetch a module.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @param single True if we're downloading a single module, false if we're downloading a whole section.
     * @param dirPath Path of the directory where to store all the content files.
     * @return Promise resolved when done.
     */
    prefetch(module: CoreCourseAnyModuleData, courseId: number, single?: boolean, dirPath?: string): Promise<void>;

    /**
     * Download the module.
     *
     * @param module The module object returned by WS.
     * @param courseId Course ID.
     * @param dirPath Path of the directory where to store all the content files.
     * @return Promise resolved when all content is downloaded.
     */
    download(module: CoreCourseAnyModuleData, courseId: number, dirPath?: string): Promise<void>;

    /**
     * Invalidate the prefetched content.
     *
     * @param moduleId The module ID.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved when the data is invalidated.
     */
    invalidateContent(moduleId: number, courseId: number): Promise<void>;

    /**
     * Check if a certain module can use core_course_check_updates to check if it has updates.
     * If not defined, it will assume all modules can be checked.
     * The modules that return false will always be shown as outdated when they're downloaded.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return Whether the module can use check_updates. The promise should never be rejected.
     */
    canUseCheckUpdates?(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean>;

    /**
     * Return the status to show based on current status. E.g. a module might want to show outdated instead of downloaded.
     * If not implemented, the original status will be returned.
     *
     * @param module Module.
     * @param status The current status.
     * @param canCheck Whether the site allows checking for updates. This parameter was deprecated since app 4.0.
     * @return Status to display.
     */
    determineStatus?(module: CoreCourseAnyModuleData, status: string, canCheck: true): string;

    /**
     * Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow).
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return Size, or promise resolved with the size.
     */
    getDownloadedSize?(module: CoreCourseAnyModuleData, courseId: number): Promise<number>;

    /**
     * Get the list of files of the module. If not defined, we'll assume they are in module.contents.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return List of files, or promise resolved with the files.
     */
    getFiles?(module: CoreCourseAnyModuleData, courseId: number): Promise<(CoreWSFile | CoreCourseModuleContentFile)[]>;

    /**
     * Check if a certain module has updates based on the result of check updates.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @param moduleUpdates List of updates for the module.
     * @return Whether the module has updates. The promise should never be rejected.
     */
    hasUpdates?(module: CoreCourseAnyModuleData, courseId: number, moduleUpdates: false | CheckUpdatesWSInstance): Promise<boolean>;

    /**
     * Invalidate WS calls needed to determine module status (usually, to check if module is downloadable).
     * It doesn't need to invalidate check updates. It should NOT invalidate files nor all the prefetched data.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved when invalidated.
     */
    invalidateModule?(module: CoreCourseAnyModuleData, courseId: number): Promise<void>;

    /**
     * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return Whether the module can be downloaded. The promise should never be rejected.
     */
    isDownloadable?(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean>;

    /**
     * Load module contents in module.contents if they aren't loaded already. This is meant for resources.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved when done.
     */
    loadContents?(module: CoreCourseAnyModuleData, courseId: number): Promise<void>;

    /**
     * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow).
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to.
     * @return Promise resolved when done.
     */
    removeFiles?(module: CoreCourseAnyModuleData, courseId: number): Promise<void>;

    /**
     * Sync a module.
     *
     * @param module Module.
     * @param courseId Course ID the module belongs to
     * @param siteId Site ID. If not defined, current site.
     * @return Promise resolved when done.
     */
    sync?(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise<unknown>;
}

type ToCheckList = {
    toCheck: CheckUpdatesToCheckWSParam[];
    cannotUse: CoreCourseModuleData[];
};

/**
 * Course updates.
 */
type CourseUpdates = Record<number, false | CheckUpdatesWSInstance>;

/**
 * Status data about a list of modules.
 */
export type CoreCourseModulesStatus = {
    total: number; // Number of modules.
    status: string; // Status of the list of modules.
    [CoreConstants.NOT_DOWNLOADED]: CoreCourseModuleData[]; // Modules with state NOT_DOWNLOADED.
    [CoreConstants.DOWNLOADED]: CoreCourseModuleData[]; // Modules with state DOWNLOADED.
    [CoreConstants.DOWNLOADING]: CoreCourseModuleData[]; // Modules with state DOWNLOADING.
    [CoreConstants.OUTDATED]: CoreCourseModuleData[]; // Modules with state OUTDATED.
};

/**
 * Data for an ongoing module prefetch.
 */
type OngoingPrefetch = {
    promise: Promise<void>; // Prefetch promise.
    observable: Subject<CoreCourseModulesProgress>; // Observable to notify the download progress.
    subscriptions: Subscription[]; // Subscriptions that are currently listening the progress.
};

/**
 * Params of core_course_check_updates WS.
 */
export type CoreCourseCheckUpdatesWSParams = {
    courseid: number; // Course id to check.
    tocheck: CheckUpdatesToCheckWSParam[]; // Instances to check.
    filter?: string[]; // Check only for updates in these areas.
};

/**
 * Data to send in tocheck parameter.
 */
type CheckUpdatesToCheckWSParam = {
    contextlevel: string; // The context level for the file location. Only module supported right now.
    id: number; // Context instance id.
    since: number; // Check updates since this time stamp.
};

/**
 * Data returned by core_course_check_updates WS.
 */
export type CoreCourseCheckUpdatesWSResponse = {
    instances: CheckUpdatesWSInstance[];
    warnings?: CoreWSExternalWarning[];
};

/**
 * Instance data returned by the WS.
 */
type CheckUpdatesWSInstance = {
    contextlevel: string; // The context level.
    id: number; // Instance id.
    updates: {
        name: string; // Name of the area updated.
        timeupdated?: number; // Last time was updated.
        itemids?: number[]; // The ids of the items updated.
    }[];
};