/**
 * Created by joel on 20/2/2018.
 *
 * This `network.ts` is the Media API arm of the netwok fetch requests.
 * It is built on `Network` object and provides standard functionality specific for MAPI.
 *
 * @see `src/network.ts` for fetching from non-Media API endpoints
 */
import { isNothing, isSome } from "@jet/environment/types/optional";
import * as serverData from "../json-parsing/server-data";
import { MetricsIdentifierType } from "../metrics/metrics-identifiers-cache";
import { ResponseMetadata } from "../network/network";
import * as urls from "../network/urls";
import { extractMetaAssociationFromData } from "./data-fetching";
import * as urlBuilder from "./url-builder";
const secondaryUserIdHeaderName = "X-Apple-AppStore-UserId-Secondary";
/**
 * Implements the MAPI fetch, building URL from MAPI Request and opaquely managing initial token request and refreshes.
 *
 * @param {Request} request MAPI Request to fetch with.
 * @param {FetchOptions} options? FetchOptions for the MAPI request.
 * @returns {Promise<Type>} Promise resolving to some type for given MAPI request.
 */
export async function fetchData(objectGraph, request, options) {
    var _a;
    const startTime = Date.now();
    const token = await objectGraph.mediaToken.refreshToken();
    const fetchTimingMetricsBuilder = objectGraph.fetchTimingMetricsBuilder;
    const requestOptions = options !== null && options !== void 0 ? options : {};
    const personalizationIdentifier = (_a = objectGraph.personalizationMetricsIdentifiersCache) === null || _a === void 0 ? void 0 : _a.getMetricsIdForType(MetricsIdentifierType.user);
    if (isSome(personalizationIdentifier)) {
        if (isSome(requestOptions === null || requestOptions === void 0 ? void 0 : requestOptions.headers)) {
            requestOptions.headers[secondaryUserIdHeaderName] = personalizationIdentifier;
        }
        else {
            requestOptions.headers = {
                [secondaryUserIdHeaderName]: personalizationIdentifier,
            };
        }
    }
    const response = await fetchWithToken(objectGraph, request, token, requestOptions, false, fetchTimingMetricsBuilder);
    const endTime = Date.now();
    if (request.canonicalUrl) {
        response[ResponseMetadata.requestedUrl] = request.canonicalUrl;
    }
    const roundTripTimeIncludingWaiting = endTime - startTime;
    if (roundTripTimeIncludingWaiting > 500) {
        const longFetchUrl = urlBuilder.buildURLFromRequest(objectGraph, request).toString();
        objectGraph.console.warn("Fetch took too long (" + roundTripTimeIncludingWaiting.toString() + "ms) " + longFetchUrl);
    }
    return response;
}
export function redirectParametersInUrl(objectGraph, url) {
    const redirectURLParams = objectGraph.bag.redirectUrlWhitelistedQueryParams;
    return redirectURLParams.filter((param) => { var _a; return serverData.isDefinedNonNull((_a = url.query) === null || _a === void 0 ? void 0 : _a[param]); });
}
/**
 * Given a built URL, token, and options, calls into native networking APIs to fetch content.
 *
 * @param {string} url URL to fetch data from.
 * @param {string} request The original request used to build this url.
 * @param {string} token MAPI token key.
 * @param {FetchOptions} options Fetch options for MAPI requests.
 * @param {boolean} isRetry flag indicating whether this is a fetch retry following a 401 request, and media token was refreshed.
 * @returns {Promise<Type>} Promise resolving to some type for given MAPI request.
 */
async function fetchWithToken(objectGraph, request, token, options = {}, isRetry = false, fetchTimingMetricsBuilder) {
    var _a, _b;
    const originalUrl = urlBuilder.buildURLFromRequest(objectGraph, request).toString();
    // Removes all affiliate/redirect params for caching (https://connectme.apple.com/docs/DOC-577671)
    const filteredURL = new urls.URL(originalUrl);
    const redirectParameters = redirectParametersInUrl(objectGraph, filteredURL);
    for (const param of redirectParameters) {
        filteredURL.removeParam(param);
    }
    const filteredUrlString = filteredURL.toString();
    let headers = options.headers;
    if (!headers) {
        headers = {};
    }
    headers["Authorization"] = "Bearer " + token;
    const response = await objectGraph.network.fetch({
        url: filteredUrlString,
        headers: headers,
        method: options.method,
        body: options.requestBodyString,
        timeout: options.timeout,
    });
    try {
        if (response.status === 401 || response.status === 403) {
            if (isRetry) {
                throw Error("We refreshed the token but we still get 401 from the API");
            }
            objectGraph.mediaToken.resetToken();
            return await objectGraph.mediaToken.refreshToken().then(async (newToken) => {
                // Explicitly re-fetch with the original request so logging and metrics are correct
                return await fetchWithToken(objectGraph, request, newToken, options, true, fetchTimingMetricsBuilder);
            });
        }
        else if (response.status === 404) {
            // item is not available in this storefront or perhaps not at all
            throw noContentError();
        }
        else if (!response.ok) {
            const error = new NetworkError(`Bad Status code ${response.status} for ${filteredUrlString}, original ${originalUrl}`);
            error.statusCode = response.status;
            throw error;
        }
        const parser = (resp) => {
            var _a;
            const parseStartTime = Date.now();
            let result;
            if (serverData.isNull(resp.body) || resp.body === "") {
                if (resp.status === 204) {
                    // 204 indicates a success, but the response will typically be empty
                    // Create a fake result so that we don't throw an error when JSON parsing
                    const emptyData = {};
                    result = emptyData;
                }
                else {
                    throw noContentError();
                }
            }
            else {
                try {
                    result = JSON.parse(resp.body);
                }
                catch (e) {
                    let errorMessage = e.message;
                    if (["debug", "internal"].includes(objectGraph.client.buildType)) {
                        errorMessage = `${e.message}, body: ${resp.body}`;
                    }
                    throw new JSONParseError(errorMessage);
                }
            }
            const parseEndTime = Date.now();
            if (result) {
                result[ResponseMetadata.pageInformation] = serverData.asJSONData(getPageInformationFromResponse(objectGraph, resp));
                if (resp.metrics.length > 0) {
                    const metrics = {
                        ...resp.metrics[0],
                        parseStartTime: parseStartTime,
                        parseEndTime: parseEndTime,
                    };
                    result[ResponseMetadata.timingValues] = metrics;
                }
                else {
                    const fallbackMetrics = {
                        pageURL: resp.url,
                        parseStartTime,
                        parseEndTime,
                    };
                    result[ResponseMetadata.timingValues] = fallbackMetrics;
                }
                result[ResponseMetadata.contentMaxAge] = getContentTimeToLiveFromResponse(objectGraph, resp);
                // If we have an empty data object, throw a 204 (No Content).
                if (Array.isArray(result.data) &&
                    serverData.isArrayDefinedNonNullAndEmpty(result.data) &&
                    !serverData.asBooleanOrFalse(options.allowEmptyDataResponse)) {
                    throw noContentError();
                }
                if (Array.isArray(result.data) && request.originalOrdering.length > 1) {
                    result.data = buildHydratedDataListResponse(objectGraph, request.originalOrdering, (_a = result.data) !== null && _a !== void 0 ? _a : [], request.supplementaryMetadataAssociations);
                }
                result[ResponseMetadata.requestedUrl] = originalUrl;
            }
            return result;
        };
        if (isSome(fetchTimingMetricsBuilder)) {
            return fetchTimingMetricsBuilder.measureParsing(response, parser);
        }
        else {
            return parser(response);
        }
    }
    catch (e) {
        if (e instanceof NetworkError) {
            throw e;
        }
        const correlationKey = (_a = response.headers["x-apple-jingle-correlation-key"]) !== null && _a !== void 0 ? _a : (_b = response.metrics[0]) === null || _b === void 0 ? void 0 : _b.clientCorrelationKey;
        throw new Error(`Error Fetching - filtered: ${filteredUrlString}, original: ${originalUrl}, correlationKey: ${correlationKey !== null && correlationKey !== void 0 ? correlationKey : "N/A"}, ${e.name}, ${e.message}`);
    }
}
export class NetworkError extends Error {
}
class JSONParseError extends Error {
}
export function noContentError() {
    const error = new NetworkError(`No content`);
    error.statusCode = 204;
    return error;
}
export function notFoundError() {
    const error = new NetworkError("Not found");
    error.statusCode = 404;
    return error;
}
const serverInstanceHeader = "x-apple-application-instance";
const environmentDataCenterHeader = "x-apple-application-site";
function getPageInformationFromResponse(objectGraph, response) {
    const storeFrontHeader = objectGraph.client.storefrontIdentifier;
    let storeFront = null;
    if ((storeFrontHeader === null || storeFrontHeader === void 0 ? void 0 : storeFrontHeader.length) > 0) {
        const storeFrontHeaderComponents = storeFrontHeader.split("-");
        if (serverData.isDefinedNonNullNonEmpty(storeFrontHeaderComponents)) {
            storeFront = storeFrontHeaderComponents[0];
        }
    }
    return {
        serverInstance: response.headers[serverInstanceHeader],
        storeFrontHeader: storeFrontHeader,
        language: objectGraph.bag.language,
        storeFront: storeFront,
        environmentDataCenter: response.headers[environmentDataCenterHeader],
    };
}
function getContentTimeToLiveFromResponse(objectGraph, response) {
    const cacheControlHeaderKey = Object.keys(response.headers).find((key) => key.toLowerCase() === "cache-control");
    if (serverData.isNull(cacheControlHeaderKey) || cacheControlHeaderKey === "") {
        return null;
    }
    const headerValue = response.headers[cacheControlHeaderKey];
    if (serverData.isNullOrEmpty(headerValue)) {
        return null;
    }
    const matches = headerValue.match(/max-age=(\d+)/);
    if (serverData.isNull(matches) || matches.length < 2) {
        return null;
    }
    return serverData.asNumber(matches[1]);
}
/**
 * Builds the hydrated list of shelf items, based on the data in the response, and the original unhydated items.
 */
function buildHydratedDataListResponse(objectGraph, unhydratedData, hydratedDataCollection, associations = []) {
    // Create a map of all the hydrated data items from the response, using the resource type and ID as the key
    const hydratedDataMap = {};
    for (const dataItem of hydratedDataCollection) {
        const key = dataMapKey(objectGraph, dataItem.type, dataItem.id);
        hydratedDataMap[key] = dataItem;
    }
    // Next iterate through the unhyrated items in their original order, and swap in the hydrated item.
    const hydratedItems = [];
    for (const unhydratedItem of unhydratedData) {
        const key = dataMapKey(objectGraph, unhydratedItem.type, unhydratedItem.id);
        const hydratedItem = hydratedDataMap[key];
        if (isSome(hydratedItem)) {
            /// Start with a base of what was there already, this is to ensure we don't lose any existing meta data
            /// if some of the requested associations are not present in the response.
            if (serverData.isDefinedNonNullNonEmpty(associations)) {
                hydratedItem.meta = {
                    ...unhydratedItem.meta,
                };
                for (const association of associations) {
                    hydrateMetaAssociationIfNecessary(objectGraph, association, hydratedItem, unhydratedItem, hydratedDataMap);
                }
            }
            hydratedItems.push(hydratedItem);
        }
    }
    return hydratedItems;
}
/**
 * Creates a key to use in a mapping of data items.
 * @param objectGraph Current object graph
 * @param data The data item
 * @returns A string composed from the data item ID and resource type
 */
function dataMapKey(objectGraph, resourceType, id) {
    return `${resourceType}_${id}`;
}
/**
 * @param objectGraph The current object graph
 * @param association The association we'd like to hydrate in the meta object
 * @param unhydratedItem The original MAPI data item to look for editorial card, and fill in with a fetched card if there is one
 * @param hydratedDataMap The data map of all the fetched items keyed by id / type
 */
function hydrateMetaAssociationIfNecessary(objectGraph, association, hydratedItem, unhydratedItem, hydratedDataMap) {
    var _a;
    if (isNothing(hydratedItem.meta)) {
        hydratedItem.meta = {
            associations: {},
        };
    }
    else if (isNothing(hydratedItem.meta.associations)) {
        hydratedItem.meta.associations = {};
    }
    const unhydratedAssociationsData = extractMetaAssociationFromData(association, unhydratedItem);
    if (serverData.isDefinedNonNullNonEmpty(unhydratedAssociationsData)) {
        const hydratedAssociationData = [];
        for (const unhydratedAssociation of unhydratedAssociationsData) {
            const associationDataKey = dataMapKey(objectGraph, unhydratedAssociation.type, unhydratedAssociation.id);
            const associationData = hydratedDataMap[associationDataKey];
            if (isSome(associationData)) {
                hydratedAssociationData.push(associationData);
            }
        }
        const hydratedAssociations = (_a = serverData.asDictionary(hydratedItem.meta.associations)) !== null && _a !== void 0 ? _a : {};
        hydratedAssociations[association] = {
            data: hydratedAssociationData,
        };
    }
}
//# sourceMappingURL=network.js.map