/**
 * Created by km on 11/17/16.
 */
/* This file DOES NOT target nodejs usage. */
import { isNothing, isSome } from "@jet/environment";
import { makeMetatype } from "@jet/environment/util/metatype";
import * as models from "../../api/models";
import * as serverData from "../../foundation/json-parsing/server-data";
import { Parameters } from "../../foundation/network/url-constants";
import * as routingComponents from "../../foundation/routing/routing-components";
import * as objects from "../../foundation/util/objects";
import { allOptional, tryAwait } from "../../foundation/util/promise-util";
import * as productPageCommon from "../product-page/product-page-common";
// region Routers
export const pageRouter = makeMetatype("app-store:page-router");
export class PageRouter {
    constructor() {
        this.registeredBuilders = new Set();
        this.pageRouter = new routingComponents.UrlRouter();
        this.shelfRouter = new routingComponents.UrlRouter();
        this.paginationRouter = new routingComponents.UrlRouter();
        // endregion
    }
    /**
     * Register a page builder containing routes and related handlers.
     * @param objectGraph
     * @param {Builder} pageBuilder to register.
     *
     * @note Although one router can serve the page, shelf, and pagination routes, we maintain
     * three instances dedicated to each instead to avoid iterating through each other's routes
     * during URL matching.
     */
    registerPageBuilder(objectGraph, pageBuilder) {
        if (this.registeredBuilders.has(pageBuilder.builderClass)) {
            throw new Error(`routing: Registering duplicate builderClass ${pageBuilder.builderClass}`);
        }
        this.registeredBuilders.add(pageBuilder.builderClass);
        this.pageRouter.associate(pageBuilder.pageRoute(objectGraph), pageBuilder);
        this.shelfRouter.associate(pageBuilder.shelfRoute(objectGraph), pageBuilder);
        this.paginationRouter.associate(pageBuilder.paginationRoute(objectGraph), pageBuilder);
    }
    /**
     * Register a shelf builder containing routes and related handlers.
     * @param objectGraph
     * @param {Builder} shelfBuilder to register.
     *
     * @note Although one router can serve the page, shelf, and pagination routes, we maintain
     * three instances dedicated to each instead to avoid iterating through each other's routes
     * during URL matching.
     */
    registerShelfBuilder(objectGraph, shelfBuilder) {
        if (this.registeredBuilders.has(shelfBuilder.builderClass)) {
            throw new Error(`routing: Registering duplicate builderClass ${shelfBuilder.builderClass}`);
        }
        this.registeredBuilders.add(shelfBuilder.builderClass);
        this.shelfRouter.associate(shelfBuilder.shelfRoute(objectGraph), shelfBuilder);
    }
    /**
     * Register a pagination builder containing routes and related handlers.
     * @param objectGraph
     * @param {Builder} paginationBuilder to register.
     *
     * @note Although one router can serve the page, shelf, and pagination routes, we maintain
     * three instances dedicated to each instead to avoid iterating through each other's routes
     * during URL matching.
     */
    registerPaginationBuilder(objectGraph, paginationBuilder) {
        if (this.registeredBuilders.has(paginationBuilder.builderClass)) {
            throw new Error(`routing: Registering duplicate builderClass ${paginationBuilder.builderClass}`);
        }
        this.registeredBuilders.add(paginationBuilder.builderClass);
        this.paginationRouter.associate(paginationBuilder.paginationRoute(objectGraph), paginationBuilder);
    }
    // endregion
    // region Exported API
    /**
     * Determine the type of destination for given `url`.
     * @param {string} url The url to fetch `flowPage` for.
     * @returns {FlowPage} The FlowPage for given `url`
     */
    fetchFlowPage(url) {
        const routerResult = this.pageRouter.routedObjectForUrl(url);
        if (!routerResult.object) {
            return `unknown`;
        }
        const builder = routerResult.object;
        // Product URLs can go to `writeReview` via deep links in iOS, so the page type may not necessary be `product`
        // So check that they are not routed to the reviews sections
        if (builder.builderClass === "ProductBuilder" &&
            routerResult.parameters[Parameters.action] !== productPageCommon.reviewsAction &&
            routerResult.parameters[Parameters.action] !== productPageCommon.writeReviewAction) {
            return "product";
        }
        return builder.pageType();
    }
    /**
     * Fetch the contents of a page.
     * @param objectGraph
     * @param url The URL to determine what kind of page to load.
     * @param pageType The meta type of the expected page, e.g. `models.ProductPage`.
     * This is used to perform a runtime type check to prevent hard to track bugs.
     * @returns A promise which will resolve into a page of `pageType`.
     */
    async fetchPage(objectGraph, url, pageType) {
        return await this.fetchAction(objectGraph, url, null, false).then(async (pageAction) => {
            return await new Promise((resolve, reject) => {
                if (!pageAction) {
                    throw new Error(`Promise resolved to null action for: ${url}`);
                }
                if (pageAction.actionClass === "FlowAction") {
                    const pageData = pageAction.pageData;
                    if (!objects.isTypeOf(pageData, pageType)) {
                        // As we have no data of the correct type, check if we have a redirect
                        const pageUrl = pageAction.pageUrl;
                        const isRedirectingToSelf = pageUrl === url;
                        if (pageUrl && !isRedirectingToSelf) {
                            // Re-process this fetch with the new URL.
                            this.fetchPage(objectGraph, pageUrl, pageType)
                                .then((page) => {
                                resolve(page);
                            })
                                .catch((error) => {
                                reject(error);
                            });
                            return;
                        }
                        reject(new Error(`pageData is not expected type ${pageType.name}, ${JSON.stringify(pageData)}`));
                        return;
                    }
                    resolve(pageData);
                    return;
                }
                else if (pageAction.actionClass === "TabChangeAction") {
                    const tabChangeAction = pageAction;
                    if (tabChangeAction.actions.length === 1 &&
                        tabChangeAction.actions[0].actionClass === "FlowAction") {
                        const pageData = tabChangeAction.actions[0].pageData;
                        if (!objects.isTypeOf(pageData, pageType)) {
                            reject(new Error(`pageData is not expected type ${pageType.name}, ${JSON.stringify(pageData)}`));
                            return;
                        }
                        resolve(pageData);
                        return;
                    }
                }
                reject(new Error("Action is not a flowAction or a tabChangeAction that contains a single flowAction."));
            });
        });
    }
    /**
     * Fetch an action for a given url, including page data if there is any to return.
     * @param url   The URL to determine which actions to take.
     * @param referrerData Optional incoming deep link referrer data.
     * @param isIncomingURL Whether the fetch is for deep link.
     * @param visitedUrls Optional set of URLs already visited in this redirect chain to prevent cycles.
     * @returns A promise that will resolve to an Action.
     */
    async fetchAction(objectGraph, url, referrerData, isIncomingURL, visitedUrls = new Set()) {
        var _a;
        const routerResult = this.pageRouter.routedObjectForUrl(url);
        if (!routerResult.object) {
            // Urls fed into `fetchAction` can redirect to supported routes, thus we attempt to
            // chain the redirect and pipe it back into `fetchAction`.
            return await this.redirectAndRefetchActionIfPossible(objectGraph, routerResult.normalizedUrl, visitedUrls);
        }
        const builder = routerResult.object;
        return await builder.handlePage(objectGraph, routerResult.normalizedUrl, (_a = routerResult.parameters) !== null && _a !== void 0 ? _a : {}, routerResult.matchedRuleIdentifier, referrerData, isIncomingURL);
    }
    /**
     * Fetch the next page of a page. The returned page will have its `shelves`, `nextPage` properties set.
     * @param pageToken The page token to use.
     * @returns A promise that will resolve to a page.
     */
    async fetchMoreOfPage(objectGraph, pageToken) {
        const url = pageToken.url;
        if (!url) {
            throw new Error("Page token missing required `url` property");
        }
        const routerResult = this.paginationRouter.routedObjectForUrl(url);
        if (!routerResult.object) {
            throw new Error(`fetchMoreOfPage: Unhandled pagination url: ${url}`);
        }
        const builder = routerResult.object;
        return await builder.handlePagination(objectGraph, routerResult.normalizedUrl, routerResult.parameters, routerResult.matchedRuleIdentifier, pageToken);
    }
    /**
     * Fetch the contents of multiple shelves, producing a shelf batch object.
     * @param requests  A hash of request keys to URL strings.
     * @returns A promise that will resolve into multiple shelves.
     */
    async fetchShelves(objectGraph, requests) {
        // eslint-disable-next-line promise/param-names
        const shelfRequestKeys = Object.keys(requests);
        const shelfUrls = shelfRequestKeys.map((key) => requests[key]);
        // Guaranteed never to throw an error
        const shelfForUrl = async (shelfUrl) => {
            if (isNothing(shelfUrl)) {
                throw new Error(`fetchShelves: Null shelf url`);
            }
            const routerResult = this.shelfRouter.routedObjectForUrl(shelfUrl);
            if (isNothing(routerResult.object)) {
                throw new Error(`fetchShelves: Unhandled shelf url: ${shelfUrl}`);
            }
            const builder = routerResult.object;
            const shelf = await builder.handleShelf(objectGraph, routerResult.normalizedUrl, routerResult.parameters, routerResult.matchedRuleIdentifier);
            return shelf;
        };
        // Map each URL to a promise and make them all optional
        const resolvedShelves = await allOptional(shelfUrls.map(shelfForUrl));
        const batch = {
            shelves: {},
            errors: {},
        };
        for (const [index, resolvedShelf] of resolvedShelves.entries()) {
            const requestKey = shelfRequestKeys[index];
            if (isNothing(requestKey)) {
                // This should never happen as we create a shelf request for each key
                continue;
            }
            if (resolvedShelf.success) {
                batch.shelves[requestKey] = resolvedShelf.value;
            }
            else {
                // For rejected promises, access the error property
                batch.errors[requestKey] = resolvedShelf.error;
            }
        }
        const hasResults = Object.keys(batch.shelves).length > 0;
        if (hasResults) {
            return batch;
        }
        else {
            const messages = Object.keys(batch.errors).map((key) => batch.errors[key].message);
            if (messages.length === 0) {
                throw new Error(`Could not load any shelves: ${JSON.stringify(requests)}`);
            }
            else {
                throw new Error(messages.join("\n"));
            }
        }
    }
    // endregion
    // region Redirect
    /**
     * Given an `url`, it will fire a GET request and then try to refetch the action for url if
     * it was redirected. This is used for urls that are unknown, but redirects to a supported url.
     *
     * @param objectGraph
     * @param {string} url to attempt to redirect with. This is expected to be normalized.
     * @param {PromiseResolveFunction<models.Action>} resolve function to resolve promise to an `Action`.
     * @param {PromiseRejectFunction} reject function to reject promise to `Error`.
     * @param {Set<string>} visitedUrls Set of URLs already visited in this redirect chain to prevent cycles.
     */
    async redirectAndRefetchActionIfPossible(objectGraph, url, visitedUrls = new Set()) {
        // Check for redirect cycle
        const urlString = url.toString();
        if (visitedUrls.has(urlString)) {
            throw new Error(`redirectAndRefetchActionIfPossible: Redirect cycle detected for URL: ${urlString}`);
        }
        // Add current URL to visited set
        visitedUrls.add(urlString);
        const fetchResult = await tryAwait(objectGraph.network.fetch({
            url: urlString,
            method: "GET",
        }));
        if (!fetchResult.success) {
            throw new Error(`redirectAndRefetchActionIfPossible: Failed to fetch page at url: ${url}`);
        }
        const response = fetchResult.value;
        if (this.hasGotoURLInResponse(objectGraph, response)) {
            // If the response body contains a goto url fail silently as we will natively try to load the URL.
            return new models.BlankAction();
        }
        else if (response.status === 200 && response.redirected && response.url) {
            // Only route if this was a redirect; otherwise we don't know how to handle this page
            return await this.fetchAction(objectGraph, response.url, null, false, visitedUrls);
        }
        else {
            throw new Error(`redirectAndRefetchActionIfPossible: Unhandled page url: ${url}`);
        }
    }
    /**
     * Given a `response`, it will check to see if the response body contains a goto URL
     * A goto URL is returned by the server in a plist as a way of navigating to a new page
     * AMSURLSession which is the native response handler looks for these URLs and tries to load them natively
     * @param objectGraph
     * @param {FetchResponse} the response body from the server
     */
    hasGotoURLInResponse(objectGraph, response) {
        if (serverData.isNullOrEmpty(response.body)) {
            return false;
        }
        try {
            const responseData = serverData.asJSONValue(objectGraph.plist.parse(response.body));
            const responseActionKind = serverData.asString(responseData, "action.kind");
            const responseActionUrl = serverData.asString(responseData, "action.url");
            if (responseActionKind === "Goto" && isSome(responseActionUrl)) {
                return true;
            }
            return false;
        }
        catch {
            return false;
        }
    }
    // endregion
    // region Testing
    /**
     * Fetch the builder for a given url.
     * @param url   The URL that the builder is associated with.
     * @returns A builder if any was associated, or null if no match was found.
     * @note FOR TESTING ONLY.
     */
    fetchBuilder(url) {
        const routerResult = this.pageRouter.routedObjectForUrl(url) ||
            this.shelfRouter.routedObjectForUrl(url) ||
            this.paginationRouter.routedObjectForUrl(url);
        return routerResult ? routerResult.object : null;
    }
}
//# sourceMappingURL=routing.js.map