// (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 { ContextLevel } from '@/core/constants';
import { Injectable } from '@angular/core';
import { CoreSiteWSPreSets, CoreSite } from '@classes/site';
import { CoreUser } from '@features/user/services/user';
import { CoreNetwork } from '@services/network';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalWarning } from '@services/ws';
import { makeSingleton } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { CoreRatingOffline } from './rating-offline';

const ROOT_CACHE_KEY = 'CoreRating:';

/**
 * Service to handle ratings.
 */
@Injectable( { providedIn: 'root' })
export class CoreRatingProvider {

    static readonly AGGREGATE_NONE = 0; // No ratings.
    static readonly AGGREGATE_AVERAGE = 1;
    static readonly AGGREGATE_COUNT = 2;
    static readonly AGGREGATE_MAXIMUM = 3;
    static readonly AGGREGATE_MINIMUM = 4;
    static readonly AGGREGATE_SUM = 5;

    static readonly UNSET_RATING = -999;

    static readonly AGGREGATE_CHANGED_EVENT = 'core_rating_aggregate_changed';
    static readonly RATING_SAVED_EVENT = 'core_rating_rating_saved';

    /**
     * Returns whether the web serivce to add ratings is available.
     *
     * @returns If WS is available.
     * @deprecated since app 4.0
     */
    isAddRatingWSAvailable(): boolean {
        return true;
    }

    /**
     * Add a rating to an item.
     *
     * @param component Component. Example: "mod_forum".
     * @param ratingArea Rating area. Example: "post".
     * @param contextLevel Context level: course, module, user, etc.
     * @param instanceId Context instance id.
     * @param itemId Item id. Example: forum post id.
     * @param itemSetId Item set id. Example: forum discussion id.
     * @param courseId Course id.
     * @param scaleId Scale id.
     * @param rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating.
     * @param ratedUserId Rated user id.
     * @param aggregateMethod Aggregate method.
     * @param siteId Site ID. If not defined, current site.
     * @returns Promise resolved with the aggregated rating or void if stored offline.
     */
    async addRating(
        component: string,
        ratingArea: string,
        contextLevel: ContextLevel,
        instanceId: number,
        itemId: number,
        itemSetId: number,
        courseId: number,
        scaleId: number,
        rating: number,
        ratedUserId: number,
        aggregateMethod: number,
        siteId?: string,
    ): Promise<CoreRatingAddRatingWSResponse | void> {
        siteId = siteId || CoreSites.getCurrentSiteId();

        // Convenience function to store a rating to be synchronized later.
        const storeOffline = async (): Promise<void> => {
            await CoreRatingOffline.addRating(
                component,
                ratingArea,
                contextLevel,
                instanceId,
                itemId,
                itemSetId,
                courseId,
                scaleId,
                rating,
                ratedUserId,
                aggregateMethod,
                siteId,
            );

            CoreEvents.trigger(CoreRatingProvider.RATING_SAVED_EVENT, {
                component,
                ratingArea,
                contextLevel,
                instanceId,
                itemSetId,
                itemId,
            }, siteId);
        };

        if (!CoreNetwork.isOnline()) {
            // App is offline, store the action.
            return storeOffline();
        }

        try {
            await CoreRatingOffline.deleteRating(component, ratingArea, contextLevel, instanceId, itemId, siteId);

            const response = await this.addRatingOnline(
                component,
                ratingArea,
                contextLevel,
                instanceId,
                itemId,
                scaleId,
                rating,
                ratedUserId,
                aggregateMethod,
                siteId,
            );

            return response;
        } catch (error) {
            if (CoreUtils.isWebServiceError(error)) {
                // The WebService has thrown an error or offline not supported, reject.
                return Promise.reject(error);
            }

            // Couldn't connect to server, store offline.
            return storeOffline();
        }
    }

    /**
     * Add a rating to an item. It will fail if offline or cannot connect.
     *
     * @param component Component. Example: "mod_forum".
     * @param ratingArea Rating area. Example: "post".
     * @param contextLevel Context level: course, module, user, etc.
     * @param instanceId Context instance id.
     * @param itemId Item id. Example: forum post id.
     * @param scaleId Scale id.
     * @param rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating.
     * @param ratedUserId Rated user id.
     * @param aggregateMethod Aggregate method.
     * @param siteId Site ID. If not defined, current site.
     * @returns Promise resolved with the aggregated rating.
     */
    async addRatingOnline(
        component: string,
        ratingArea: string,
        contextLevel: ContextLevel,
        instanceId: number,
        itemId: number,
        scaleId: number,
        rating: number,
        ratedUserId: number,
        aggregateMethod: number,
        siteId?: string,
    ): Promise<CoreRatingAddRatingWSResponse> {

        const site = await CoreSites.getSite(siteId);
        const params: CoreRatingAddRatingWSParams = {
            contextlevel: contextLevel,
            instanceid: instanceId,
            component,
            ratingarea: ratingArea,
            itemid: itemId,
            scaleid: scaleId,
            rating,
            rateduserid: ratedUserId,
            aggregation: aggregateMethod,
        };

        const response = await site.write<CoreRatingAddRatingWSResponse>('core_rating_add_rating', params);

        await this.invalidateRatingItems(contextLevel, instanceId, component, ratingArea, itemId, scaleId);

        CoreEvents.trigger(CoreRatingProvider.AGGREGATE_CHANGED_EVENT, {
            contextLevel,
            instanceId,
            component,
            ratingArea,
            itemId,
            aggregate: response.aggregate,
            count: response.count,
        });

        return response;
    }

    /**
     * Get item ratings.
     *
     * @param contextLevel Context level: course, module, user, etc.
     * @param instanceId Context instance id.
     * @param component Component. Example: "mod_forum".
     * @param ratingArea Rating area. Example: "post".
     * @param itemId Item id. Example: forum post id.
     * @param scaleId Scale id.
     * @param sort Sort field.
     * @param courseId Course id. Used for fetching user profiles.
     * @param siteId Site ID. If not defined, current site.
     * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
     * @returns Promise resolved with the list of ratings.
     */
    async getItemRatings(
        contextLevel: ContextLevel,
        instanceId: number,
        component: string,
        ratingArea: string,
        itemId: number,
        scaleId: number,
        sort: string = 'timemodified',
        courseId?: number,
        siteId?: string,
        ignoreCache: boolean = false,
    ): Promise<CoreRatingItemRating[]> {
        const site = await CoreSites.getSite(siteId);

        const params: CoreRatingGetItemRatingsWSParams = {
            contextlevel: contextLevel,
            instanceid: instanceId,
            component,
            ratingarea: ratingArea,
            itemid: itemId,
            scaleid: scaleId,
            sort,
        };

        const preSets: CoreSiteWSPreSets = {
            cacheKey: this.getItemRatingsCacheKey(contextLevel, instanceId, component, ratingArea, itemId, scaleId, sort),
            updateFrequency: CoreSite.FREQUENCY_RARELY,
        };

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

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

        if (!site.isVersionGreaterEqualThan([' 3.6.5', '3.7.1', '3.8'])) {
            // MDL-65042 We need to fetch profiles because the returned profile pictures are incorrect.
            const promises = response.ratings.map((rating: CoreRatingItemRating) =>
                CoreUser.getProfile(rating.userid, courseId, true, site.id).then((user) => {
                    rating.userpictureurl = user.profileimageurl || '';

                    return;
                }).catch(() => {
                    // Ignore error.
                    rating.userpictureurl = '';
                }));

            await Promise.all(promises);
        }

        return response.ratings;
    }

    /**
     * Invalidate item ratings.
     *
     * @param contextLevel Context level: course, module, user, etc.
     * @param instanceId Context instance id.
     * @param component Component. Example: "mod_forum".
     * @param ratingArea Rating area. Example: "post".
     * @param itemId Item id. Example: forum post id.
     * @param scaleId Scale id.
     * @param sort Sort field.
     * @param siteId Site ID. If not defined, current site.
     * @returns Promise resolved when the data is invalidated.
     */
    async invalidateRatingItems(
        contextLevel: ContextLevel,
        instanceId: number,
        component: string,
        ratingArea: string,
        itemId: number,
        scaleId: number,
        sort: string = 'timemodified',
        siteId?: string,
    ): Promise<void> {
        const site = await CoreSites.getSite(siteId);

        const key = this.getItemRatingsCacheKey(contextLevel, instanceId, component, ratingArea, itemId, scaleId, sort);

        await site.invalidateWsCacheForKey(key);
    }

    /**
     * Check if rating is disabled in a certain site.
     *
     * @param site Site. If not defined, use current site.
     * @returns Whether it's disabled.
     */
    isRatingDisabledInSite(site?: CoreSite): boolean {
        site = site || CoreSites.getCurrentSite();

        return !!site?.isFeatureDisabled('NoDelegate_CoreRating');
    }

    /**
     * Check if rating is disabled in a certain site.
     *
     * @param siteId Site Id. If not defined, use current site.
     * @returns Promise resolved with true if disabled, rejected or resolved with false otherwise.
     */
    async isRatingDisabled(siteId?: string): Promise<boolean> {
        const site = await CoreSites.getSite(siteId);

        return this.isRatingDisabledInSite(site);
    }

    /**
     * Convenience function to merge two or more rating infos of the same instance.
     *
     * @param ratingInfos Array of rating infos.
     * @returns Merged rating info or undefined.
     */
    mergeRatingInfos(ratingInfos: CoreRatingInfo[]): CoreRatingInfo | undefined {
        let result: CoreRatingInfo | undefined;
        const scales: Record<number, CoreRatingScale> = {};
        const ratings: Record<number, CoreRatingInfoItem> = {};

        ratingInfos.forEach((ratingInfo) => {
            if (!ratingInfo) {
                // Skip null rating infos.
                return;
            }

            if (!result) {
                result = Object.assign({}, ratingInfo);
            }

            (ratingInfo.scales || []).forEach((scale) => {
                scales[scale.id] = scale;
            });

            (ratingInfo.ratings || []).forEach((rating) => {
                ratings[rating.itemid] = rating;
            });
        });

        if (result) {
            result.scales = CoreUtils.objectToArray(scales);
            result.ratings = CoreUtils.objectToArray(ratings);
        }

        return result;
    }

    /**
     * Prefetch individual ratings.
     *
     * This function should be called from the prefetch handler of activities with ratings.
     *
     * @param contextLevel Context level: course, module, user, etc.
     * @param instanceId Instance Id.
     * @param scaleId Scale Id.
     * @param courseId Course id. Used for prefetching user profiles.
     * @param ratingInfo Rating info returned by web services.
     * @param siteId Site id. If not defined, current site.
     * @returns Promise resolved when done.
     */
    async prefetchRatings(
        contextLevel: ContextLevel,
        instanceId: number,
        scaleId: number,
        courseId?: number,
        ratingInfo?: CoreRatingInfo,
        siteId?: string,
    ): Promise<void> {
        if (!ratingInfo || !ratingInfo.ratings) {
            return;
        }

        const site = await CoreSites.getSite(siteId);
        const promises = ratingInfo.ratings.map((item) => this.getItemRatings(
            contextLevel,
            instanceId,
            ratingInfo.component,
            ratingInfo.ratingarea,
            item.itemid,
            scaleId,
            undefined,
            courseId,
            site.id,
            true,
        ));

        const ratingsResults = await Promise.all(promises);

        if (!site.isVersionGreaterEqualThan([' 3.6.5', '3.7.1', '3.8'])) {
            const ratings: CoreRatingItemRating[] = [].concat.apply([], ratingsResults);

            const userIds = ratings.map((rating) => rating.userid);

            await CoreUser.prefetchProfiles(userIds, courseId, site.id);
        }
    }

    /**
     * Get cache key for rating items WS calls.
     *
     * @param contextLevel Context level: course, module, user, etc.
     * @param instanceId Instance Id.
     * @param component Component. Example: "mod_forum".
     * @param ratingArea Rating area. Example: "post".
     * @param itemId Item id. Example: forum post id.
     * @param scaleId Scale Id.
     * @param sort Sort field.
     * @returns Cache key.
     */
    protected getItemRatingsCacheKey(
        contextLevel: ContextLevel,
        instanceId: number,
        component: string,
        ratingArea: string,
        itemId: number,
        scaleId: number,
        sort: string,
    ): string {
        return `${ROOT_CACHE_KEY}${contextLevel}:${instanceId}:${component}:${ratingArea}:${itemId}:${scaleId}:${sort}`;
    }

}
export const CoreRating = makeSingleton(CoreRatingProvider);

declare module '@singletons/events' {

    /**
     * Augment CoreEventsData interface with events specific to this service.
     *
     * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
     */
    export interface CoreEventsData {
        [CoreRatingProvider.AGGREGATE_CHANGED_EVENT]: CoreRatingAggregateChangedEventData;
        [CoreRatingProvider.RATING_SAVED_EVENT]: CoreRatingSavedEventData;
    }

}

/**
 * Structure of the rating info returned by web services.
 */
export type CoreRatingInfo = {
    contextid: number; // Context id.
    component: string; // Context name.
    ratingarea: string; // Rating area name.
    canviewall?: boolean; // Whether the user can view all the individual ratings.
    canviewany?: boolean; // Whether the user can view aggregate of ratings of others.
    scales?: CoreRatingScale[]; // Different scales used information.
    ratings?: CoreRatingInfoItem[]; // The ratings.
};

/**
 * Structure of scales in the rating info.
 */
export type CoreRatingScale = {
    id: number; // Scale id.
    courseid?: number; // Course id.
    name?: string; // Scale name (when a real scale is used).
    max: number; // Max value for the scale.
    isnumeric: boolean; // Whether is a numeric scale.
    items?: { // Scale items. Only returned for not numerical scales.
        value: number; // Scale value/option id.
        name: string; // Scale name.
    }[];
};

/**
 * Structure of items in the rating info.
 */
export type CoreRatingInfoItem = {
    itemid: number; // Item id.
    scaleid?: number; // Scale id.
    scale?: CoreRatingScale; // Added for rendering purposes.
    userid?: number; // User who rated id.
    aggregate?: number; // Aggregated ratings grade.
    aggregatestr?: string; // Aggregated ratings as string.
    aggregatelabel?: string; // The aggregation label.
    count?: number; // Ratings count (used when aggregating).
    rating?: number; // The rating the user gave.
    canrate?: boolean; // Whether the user can rate the item.
    canviewaggregate?: boolean; // Whether the user can view the aggregated grade.
};

/**
 * Structure of a rating returned by the item ratings web service.
 */
export type CoreRatingItemRating = {
    id: number; // Rating id.
    userid: number; // User id.
    userpictureurl: string; // URL user picture.
    userfullname: string; // User fullname.
    rating: string; // Rating on scale.
    timemodified: number; // Time modified (timestamp).
};

/**
 * Params of core_rating_get_item_ratings WS.
 */
type CoreRatingGetItemRatingsWSParams = {
    contextlevel: ContextLevel; // Context level: course, module, user, etc...
    instanceid: number; // The instance id of item associated with the context level.
    component: string; // Component.
    ratingarea: string; // Rating area.
    itemid: number; // Associated id.
    scaleid: number; // Scale id.
    sort: string; // Sort order (firstname, rating or timemodified).
};

/**
 * Data returned by core_rating_get_item_ratings WS.
 */
export type CoreRatingGetItemRatingsWSResponse = {
    ratings: CoreRatingItemRating[];
    warnings?: CoreWSExternalWarning[];
};

/**
 * Params of core_rating_add_rating WS.
 */
type CoreRatingAddRatingWSParams = {
    contextlevel: ContextLevel; // Context level: course, module, user, etc...
    instanceid: number; // The instance id of item associated with the context level.
    component: string; // Component.
    ratingarea: string; // Rating area.
    itemid: number; // Associated id.
    scaleid: number; // Scale id.
    rating: number; // User rating.
    rateduserid: number; // Rated user id.
    aggregation?: number; // Agreggation method.
};

/**
 * Data returned by core_rating_add_rating WS.
 */
export type CoreRatingAddRatingWSResponse = {
    success: boolean; // Whether the rate was successfully created.
    aggregate?: string; // New aggregate.
    count?: number; // Ratings count.
    itemid?: number; // Rating item id.
    warnings?: CoreWSExternalWarning[];
};

/**
 * Data sent by AGGREGATE_CHANGED_EVENT event.
 */
export type CoreRatingAggregateChangedEventData = {
    contextLevel: ContextLevel;
    instanceId: number;
    component: string;
    ratingArea: string;
    itemId: number;
    aggregate?: string;
    count?: number;
};

/**
 * Data sent by RATING_SAVED_EVENT event.
 */
export type CoreRatingSavedEventData = {
    component: string;
    ratingArea: string;
    contextLevel: ContextLevel;
    instanceId: number;
    itemSetId: number;
    itemId: number;
};
