/**
 * Copyright 2015 CANAL+ Group
 *
 * 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.
 */
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
var __generator = (this && this.__generator) || function (thisArg, body) {
    var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
    return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
    function verb(n) { return function (v) { return step([n, v]); }; }
    function step(op) {
        if (f) throw new TypeError("Generator is already executing.");
        while (g && (g = 0, op[0] && (_ = 0)), _) try {
            if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
            if (y = 0, t) op = [op[0] & 2, t.value];
            switch (op[0]) {
                case 0: case 1: t = op; break;
                case 4: _.label++; return { value: op[1], done: false };
                case 5: _.label++; y = op[1]; op = [0]; continue;
                case 7: op = _.ops.pop(); _.trys.pop(); continue;
                default:
                    if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
                    if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
                    if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
                    if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
                    if (t[2]) _.ops.pop();
                    _.trys.pop(); continue;
            }
            op = body.call(thisArg, _);
        } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
        if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
    }
};
import { isOffline } from "../../../compat";
import { CustomLoaderError, isKnownError, NetworkErrorTypes, RequestError, } from "../../../errors";
import log from "../../../log";
import cancellableSleep from "../../../utils/cancellable_sleep";
import getFuzzedDelay from "../../../utils/get_fuzzed_delay";
import noop from "../../../utils/noop";
import TaskCanceller from "../../../utils/task_canceller";
/**
 * Called on a loader error.
 * Returns whether the loader request should be retried.
 *
 * TODO the notion of retrying or not could be transport-specific (e.g. 412 are
 * mainly used for Smooth contents) and thus as part of the transport code (e.g.
 * by rejecting with an error always having a `canRetry` property?).
 * Or not, to ponder.
 *
 * @param {Error} error
 * @returns {Boolean} - If true, the request can be retried.
 */
function shouldRetry(error) {
    if (error instanceof RequestError) {
        if (error.type === NetworkErrorTypes.ERROR_HTTP_CODE) {
            return error.status >= 500 ||
                error.status === 404 ||
                error.status === 415 || // some CDN seems to use that code when
                // requesting low-latency segments too much
                // in advance
                error.status === 412;
        }
        return error.type === NetworkErrorTypes.TIMEOUT ||
            error.type === NetworkErrorTypes.ERROR_EVENT;
    }
    else if (error instanceof CustomLoaderError) {
        if (typeof error.canRetry === "boolean") {
            return error.canRetry;
        }
        if (error.xhr !== undefined) {
            return error.xhr.status >= 500 ||
                error.xhr.status === 404 ||
                error.xhr.status === 415 || // some CDN seems to use that code when
                // requesting low-latency segments too much
                // in advance
                error.xhr.status === 412;
        }
        return false;
    }
    return isKnownError(error) && error.code === "INTEGRITY_ERROR";
}
/**
 * Returns true if we're pretty sure that the current error is due to the
 * user being offline.
 * @param {Error} error
 * @returns {Boolean}
 */
function isOfflineRequestError(error) {
    if (error instanceof RequestError) {
        return error.type === NetworkErrorTypes.ERROR_EVENT &&
            isOffline();
    }
    else if (error instanceof CustomLoaderError) {
        return error.isOfflineError;
    }
    return false; // under doubt, return false
}
/**
 * Guess the type of error obtained.
 * @param {*} error
 * @returns {number}
 */
function getRequestErrorType(error) {
    return isOfflineRequestError(error) ? 2 /* REQUEST_ERROR_TYPES.Offline */ :
        1 /* REQUEST_ERROR_TYPES.Regular */;
}
/**
 * Specific algorithm used to perform segment and manifest requests.
 *
 * Here how it works:
 *
 *   1. You give it one or multiple of the CDN available for the resource you
 *      want to request (from the most important one to the least important),
 *      a callback doing the request with the chosen CDN in argument, and some
 *      options.
 *
 *   2. it tries to call the request callback with the most prioritized CDN
 *      first:
 *        - if it works as expected, it resolves the returned Promise with that
 *          request's response.
 *        - if it fails, it calls ther `onRetry` callback given with the
 *          corresponding error, un-prioritize that CDN and try with the new
 *          most prioritized CDN.
 *
 *      Each CDN might be retried multiple times, depending on the nature of the
 *      error and the Configuration given.
 *
 *      Multiple retries of the same CDN are done after a delay to avoid
 *      overwhelming it, this is what we call a "backoff". That delay raises
 *      exponentially as multiple consecutive errors are encountered on this
 *      CDN.
 *
 * @param {Array.<string>|null} cdns - The different CDN on which the
 * wanted resource is available. `scheduleRequestWithCdns` will call the
 * `performRequest` callback with the right element from that array if different
 * from `null`.
 *
 * Can be set to `null` when that resource is not reachable through a CDN, in
 * which case the `performRequest` callback may be called with `null`.
 * @param {Object|null} cdnPrioritizer - Interface allowing to give the priority
 * between multiple CDNs.
 * @param {Function} performRequest - Callback implementing the request in
 * itself. Resolving when the resource request succeed and rejecting with the
 * corresponding error when the request failed.
 * @param {Object} options - Configuration allowing to tweak the number on which
 * the algorithm behind `scheduleRequestWithCdns` bases itself.
 * @param {Object} cancellationSignal - CancellationSignal allowing to cancel
 * the logic of `scheduleRequestWithCdns`.
 * To trigger if the resource is not needed anymore.
 * @returns {Promise} - Promise resolving, with the corresponding
 * `performRequest`'s data, when the resource request succeed and rejecting in
 * the following scenarios:
 *   - `scheduleRequestWithCdns` has been cancelled due to `cancellationSignal`
 *     being triggered. In that case a `CancellationError` is thrown.
 *
 *   - The resource request(s) failed and will not be retried anymore.
 */
export function scheduleRequestWithCdns(cdns, cdnPrioritizer, performRequest, options, cancellationSignal) {
    return __awaiter(this, void 0, void 0, function () {
        /**
         * Returns what is now the most prioritary CDN to request the wanted resource.
         *
         * A return value of `null` indicates that the resource can be requested
         * through another mean than by doing an HTTP request.
         *
         * A return value of `undefined` indicates that there's no CDN left to request
         * the resource.
         * @returns {Object|null|undefined}
         */
        function getCdnToRequest() {
            if (cdns === null) {
                var nullAttemptObject = missedAttempts.get(null);
                if (nullAttemptObject !== undefined && nullAttemptObject.isBlacklisted) {
                    return undefined;
                }
                return null;
            }
            else if (cdnPrioritizer === null) {
                return getPrioritaryRequestableCdnFromSortedList(cdns);
            }
            else {
                var prioritized = cdnPrioritizer.getCdnPreferenceForResource(cdns);
                return getPrioritaryRequestableCdnFromSortedList(prioritized);
            }
        }
        /**
         * Perform immediately the request for the given CDN.
         *
         * If it fails, forbid the CDN from being used - optionally and in some
         * conditions, only temporarily, then try the next CDN according to
         * previously-set delays (with a potential sleep before to respect them).
         *
         * Reject if both the request fails and there's no CDN left to use.
         * @param {string|null} cdn
         * @returns {Promise}
         */
        function requestCdn(cdn) {
            return __awaiter(this, void 0, void 0, function () {
                var res, error_1, currentErrorType, missedAttemptsObj, maxRetry, errorCounter, delay, fuzzedDelay;
                return __generator(this, function (_a) {
                    switch (_a.label) {
                        case 0:
                            _a.trys.push([0, 2, , 3]);
                            return [4 /*yield*/, performRequest(cdn, cancellationSignal)];
                        case 1:
                            res = _a.sent();
                            return [2 /*return*/, res];
                        case 2:
                            error_1 = _a.sent();
                            if (TaskCanceller.isCancellationError(error_1)) {
                                throw error_1;
                            }
                            if (cdn !== null && cdnPrioritizer !== null) {
                                // We failed requesting the resource on this CDN.
                                // Globally give priority to the next CDN through the CdnPrioritizer.
                                cdnPrioritizer.downgradeCdn(cdn);
                            }
                            currentErrorType = getRequestErrorType(error_1);
                            missedAttemptsObj = missedAttempts.get(cdn);
                            if (missedAttemptsObj === undefined) {
                                missedAttemptsObj = { errorCounter: 1,
                                    lastErrorType: currentErrorType,
                                    blockedUntil: undefined,
                                    isBlacklisted: false };
                                missedAttempts.set(cdn, missedAttemptsObj);
                            }
                            else {
                                if (currentErrorType !== missedAttemptsObj.lastErrorType) {
                                    missedAttemptsObj.errorCounter = 1;
                                    missedAttemptsObj.lastErrorType = currentErrorType;
                                }
                                else {
                                    missedAttemptsObj.errorCounter++;
                                }
                            }
                            if (!shouldRetry(error_1)) {
                                missedAttemptsObj.blockedUntil = undefined;
                                missedAttemptsObj.isBlacklisted = true;
                                return [2 /*return*/, retryWithNextCdn(error_1)];
                            }
                            maxRetry = currentErrorType === 2 /* REQUEST_ERROR_TYPES.Offline */ ?
                                maxRetryOffline :
                                maxRetryRegular;
                            if (missedAttemptsObj.errorCounter > maxRetry) {
                                missedAttemptsObj.blockedUntil = undefined;
                                missedAttemptsObj.isBlacklisted = true;
                            }
                            else {
                                errorCounter = missedAttemptsObj.errorCounter;
                                delay = Math.min(baseDelay * Math.pow(2, errorCounter - 1), maxDelay);
                                fuzzedDelay = getFuzzedDelay(delay);
                                missedAttemptsObj.blockedUntil = performance.now() + fuzzedDelay;
                            }
                            return [2 /*return*/, retryWithNextCdn(error_1)];
                        case 3: return [2 /*return*/];
                    }
                });
            });
        }
        /**
         * After a request error, find the new most prioritary CDN and perform the
         * request with it, optionally after a delay.
         *
         * If there's no CDN left to test, reject the original request error.
         * @param {*} prevRequestError
         * @returns {Promise}
         */
        function retryWithNextCdn(prevRequestError) {
            return __awaiter(this, void 0, void 0, function () {
                var nextCdn;
                return __generator(this, function (_a) {
                    nextCdn = getCdnToRequest();
                    if (cancellationSignal.isCancelled()) {
                        throw cancellationSignal.cancellationError;
                    }
                    if (nextCdn === undefined) {
                        throw prevRequestError;
                    }
                    onRetry(prevRequestError);
                    if (cancellationSignal.isCancelled()) {
                        throw cancellationSignal.cancellationError;
                    }
                    return [2 /*return*/, waitPotentialBackoffAndRequest(nextCdn, prevRequestError)];
                });
            });
        }
        /**
         * Request the corresponding CDN after the optional backoff needed before
         * requesting it.
         *
         * If a new CDN become prioritary in the meantime, request it instead, again
         * awaiting its optional backoff delay if it exists.
         * @param {string|null} nextWantedCdn
         * @param {*} prevRequestError
         * @returns {Promise}
         */
        function waitPotentialBackoffAndRequest(nextWantedCdn, prevRequestError) {
            var nextCdnAttemptObj = missedAttempts.get(nextWantedCdn);
            if (nextCdnAttemptObj === undefined ||
                nextCdnAttemptObj.blockedUntil === undefined) {
                return requestCdn(nextWantedCdn);
            }
            var now = performance.now();
            var blockedFor = nextCdnAttemptObj.blockedUntil - now;
            if (blockedFor <= 0) {
                return requestCdn(nextWantedCdn);
            }
            var canceller = new TaskCanceller();
            var unlinkCanceller = canceller.linkToSignal(cancellationSignal);
            return new Promise(function (res, rej) {
                /* eslint-disable-next-line @typescript-eslint/no-misused-promises */
                cdnPrioritizer === null || cdnPrioritizer === void 0 ? void 0 : cdnPrioritizer.addEventListener("priorityChange", function () {
                    var updatedPrioritaryCdn = getCdnToRequest();
                    if (cancellationSignal.isCancelled()) {
                        throw cancellationSignal.cancellationError;
                    }
                    if (updatedPrioritaryCdn === undefined) {
                        return cleanAndReject(prevRequestError);
                    }
                    if (updatedPrioritaryCdn !== nextWantedCdn) {
                        canceller.cancel();
                        waitPotentialBackoffAndRequest(updatedPrioritaryCdn, prevRequestError)
                            .then(cleanAndResolve, cleanAndReject);
                    }
                }, canceller.signal);
                cancellableSleep(blockedFor, canceller.signal)
                    .then(function () { return requestCdn(nextWantedCdn)
                    .then(cleanAndResolve, cleanAndReject); }, noop);
                function cleanAndResolve(response) {
                    unlinkCanceller();
                    res(response);
                }
                function cleanAndReject(err) {
                    unlinkCanceller();
                    rej(err);
                }
            });
        }
        /**
         * Takes in input the list of CDN that can be used to request the resource, in
         * a general preference order.
         *
         * Returns the actual most prioritary Cdn to request, based on the current
         * attempts already done for that resource.
         *
         * Returns `undefined` if there's no Cdn left to request the resource.
         * @param {Array.<Object>}
         * @returns {Object|undefined}
         */
        function getPrioritaryRequestableCdnFromSortedList(sortedCdns) {
            var _a;
            if (missedAttempts.size === 0) {
                return sortedCdns[0];
            }
            var now = performance.now();
            return (_a = sortedCdns
                .filter(function (c) { var _a; return ((_a = missedAttempts.get(c)) === null || _a === void 0 ? void 0 : _a.isBlacklisted) !== true; })
                .reduce(function (acc, x) {
                var _a;
                var blockedUntil = (_a = missedAttempts.get(x)) === null || _a === void 0 ? void 0 : _a.blockedUntil;
                if (blockedUntil !== undefined && blockedUntil <= now) {
                    blockedUntil = undefined;
                }
                if (acc === undefined) {
                    return [x, blockedUntil];
                }
                if (blockedUntil === undefined) {
                    if (acc[1] === undefined) {
                        return acc;
                    }
                    return [x, undefined];
                }
                return acc[1] === undefined ? acc :
                    blockedUntil < acc[1] ? [x, blockedUntil] :
                        acc;
            }, undefined)) === null || _a === void 0 ? void 0 : _a[0];
        }
        var baseDelay, maxDelay, maxRetryRegular, maxRetryOffline, onRetry, missedAttempts, initialCdnToRequest;
        return __generator(this, function (_a) {
            if (cancellationSignal.cancellationError !== null) {
                return [2 /*return*/, Promise.reject(cancellationSignal.cancellationError)];
            }
            baseDelay = options.baseDelay, maxDelay = options.maxDelay, maxRetryRegular = options.maxRetryRegular, maxRetryOffline = options.maxRetryOffline, onRetry = options.onRetry;
            if (cdns !== null && cdns.length === 0) {
                log.warn("Fetchers: no CDN given to `scheduleRequestWithCdns`.");
            }
            missedAttempts = new Map();
            initialCdnToRequest = getCdnToRequest();
            if (initialCdnToRequest === undefined) {
                throw new Error("No CDN to request");
            }
            return [2 /*return*/, requestCdn(initialCdnToRequest)];
        });
    });
}
/**
 * Lightweight version of the request algorithm, this time with only a simple
 * Promise given.
 * @param {Function} performRequest
 * @param {Object} options
 * @returns {Promise}
 */
export function scheduleRequestPromise(performRequest, options, cancellationSignal) {
    // same than for a single unknown CDN
    return scheduleRequestWithCdns(null, null, performRequest, options, cancellationSignal);
}
